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,35 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.32
|
||||
version: 0.1.33
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
Generated
+54
-3
@@ -226,7 +226,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"toml_edit",
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1221,6 +1221,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,7 +1245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.31"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
@@ -1254,6 +1263,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sysinfo",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -1378,6 +1388,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 +1418,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 +1439,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 +1453,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"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -22,6 +22,7 @@ crossbeam-channel = "0.5"
|
||||
sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
toml = "0.8"
|
||||
|
||||
[features]
|
||||
default = ["gauntlet"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,16 @@
|
||||
[package]
|
||||
name = "econ-sim"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Settled Reach economics simulation — Layer 1 Leontief production + price adjustment"
|
||||
|
||||
[[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,226 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EconEvent — D-180 event port stub (#809)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scope of nodes affected by an EconEvent.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum EventTarget {
|
||||
Node(String),
|
||||
NodeSet(Vec<String>),
|
||||
Corridor(String),
|
||||
TradeRoute { from: String, to: String },
|
||||
Currency(String),
|
||||
Commodity(String),
|
||||
}
|
||||
|
||||
/// Economic effect applied at the target.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum EventEffect {
|
||||
ProductivityMultiplier(f64),
|
||||
CapacityMultiplier(f64),
|
||||
DemandShock(f64),
|
||||
ExchangeShock(f64),
|
||||
}
|
||||
|
||||
/// Who can observe this event.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum EventVisibility {
|
||||
Global,
|
||||
Proximate(u32), // hops
|
||||
Disclosed(Vec<String>), // specific node IDs
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// Economic event for injection into the simulation (D-180).
|
||||
///
|
||||
/// No-op handler until the IPC bridge is in place.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct EconEvent {
|
||||
pub target: EventTarget,
|
||||
pub effect: EventEffect,
|
||||
/// Duration in simulation ticks. 0 = instantaneous.
|
||||
pub duration: u32,
|
||||
pub visibility: EventVisibility,
|
||||
}
|
||||
|
||||
/// No-op event handler. Called from the tick loop once D-180 IPC is wired.
|
||||
#[allow(dead_code)]
|
||||
pub fn handle_event(_event: &EconEvent) {
|
||||
// No-op: event port not yet connected (D-180).
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,150 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// 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,431 @@
|
||||
//! 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 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: no-explosion check (price bounds over 1000-tick run)
|
||||
// Note: this is NOT a D-179 shock injection test. Full shock-response
|
||||
// testing (inject → cascade → recovery) requires D-180 event port.
|
||||
// -----------------------------------------------------------------
|
||||
let (test3_pass, test3_note) = run_no_explosion_check(economy, &records);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 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 (no-explosion check — price bounds over 1000 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify no price explosions or negative prices in the 1000-tick run.
|
||||
///
|
||||
/// This is NOT a D-179 shock injection test. D-179 Test 3 requires deliberate
|
||||
/// shock injection via the D-180 event port, which is not yet implemented.
|
||||
/// This check validates the weaker property: the model does not produce
|
||||
/// unbounded prices (>20× base) or negative prices over 1000 ticks.
|
||||
fn run_no_explosion_check(
|
||||
economy: &db::Economy,
|
||||
records_1000: &[model::TickRecord],
|
||||
) -> (bool, String) {
|
||||
const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price
|
||||
|
||||
// Check: no price > 20× base at any tick
|
||||
let mut explosion_detected = false;
|
||||
let mut explosion_worst = String::new();
|
||||
for r in records_1000 {
|
||||
let base = economy
|
||||
.commodity_map
|
||||
.get(&r.commodity_id)
|
||||
.map_or(1.0, |c| c.base_price);
|
||||
if r.price > base * PRICE_EXPLOSION_LIMIT {
|
||||
explosion_detected = true;
|
||||
explosion_worst = format!(
|
||||
"{}/{} price={:.1} base={:.1} ({:.0}×)",
|
||||
r.node_id,
|
||||
r.commodity_id,
|
||||
r.price,
|
||||
base,
|
||||
r.price / base
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if explosion_detected {
|
||||
return (false, format!("price explosion: {}", explosion_worst));
|
||||
}
|
||||
|
||||
// Check: no negative prices (should be clamped by model, verify here)
|
||||
if let Some(r) = records_1000.iter().find(|r| r.price < 0.0) {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"{}/{} price went negative: {}",
|
||||
r.node_id, r.commodity_id, r.price
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
true,
|
||||
format!(
|
||||
"no explosions (>{:.0}× base), no negatives across {} records",
|
||||
PRICE_EXPLOSION_LIMIT,
|
||||
records_1000.len()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,398 @@
|
||||
//! Layer 1: Leontief production + consumption + price adjustment.
|
||||
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||||
//!
|
||||
//! 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).
|
||||
//!
|
||||
//! Layer 3 (corporate behavioral agents) is added in #809.
|
||||
//!
|
||||
//! Reference: D-178 (Economic Model Architecture)
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::agents;
|
||||
use crate::currency::{CurrencyState, ShadowEconomy};
|
||||
use crate::db::Economy;
|
||||
use crate::seed::Productivity;
|
||||
use crate::trade;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Price adjustment rate per tick (α=0.03, D-178 Layer 2).
|
||||
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 simulation for `ticks` ticks.
|
||||
///
|
||||
/// 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).
|
||||
///
|
||||
/// 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 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 {
|
||||
step(economy, productivity, shadow, &archetypes, &mut nodes);
|
||||
trade::trade_step(economy, &mut nodes, adjacency, &mut currency);
|
||||
currency.update_rate();
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
fn step(
|
||||
economy: &Economy,
|
||||
productivity: &BTreeMap<(String, String), Productivity>,
|
||||
shadow: &ShadowEconomy,
|
||||
archetypes: &BTreeMap<String, agents::Archetype>,
|
||||
nodes: &mut BTreeMap<String, NodeState>,
|
||||
) {
|
||||
// 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());
|
||||
|
||||
// Effective baseline = BASELINE_CAPACITY scaled by archetype
|
||||
let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale;
|
||||
|
||||
// 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).
|
||||
let gross_output = effective_capacity * prod.extraction_rate;
|
||||
// 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 multiplier
|
||||
let prod_mult = prod.for_tier(&chain_output_tier(economy, chain));
|
||||
let gross_output =
|
||||
effective_capacity * chain.output_quantity * capacity_fraction * prod_mult;
|
||||
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.
|
||||
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 raw_demand = if commodity.id == "fusion_fuel"
|
||||
&& system_info.gate_energy_connected
|
||||
&& commodity.tier != "raw"
|
||||
{
|
||||
base_demand * GATE_ENERGY_DEMAND_REDUCTION
|
||||
} else {
|
||||
base_demand
|
||||
};
|
||||
|
||||
// Shadow economy reduces formal-sector consumption (some demand met off-books)
|
||||
let demand = raw_demand * (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,163 @@
|
||||
//! 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.
|
||||
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,
|
||||
) {
|
||||
// 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" "$@"
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"GJ0f-1": {
|
||||
"pois": [
|
||||
{"id": "poi_loki_patera", "name": "Loki Patera", "center": [115, 295], "kind": "volcano"},
|
||||
{"id": "poi_pele", "name": "Pele", "center": [140, 358], "kind": "volcano"},
|
||||
{"id": "poi_tvashtar", "name": "Tvashtar Paterae","center": [46, 354], "kind": "volcano"},
|
||||
{"id": "poi_prometheus", "name": "Prometheus", "center": [128, 412], "kind": "volcano"},
|
||||
{"id": "poi_masubi", "name": "Masubi", "center": [152, 370], "kind": "volcano"}
|
||||
]
|
||||
},
|
||||
"GJ0f-2": {
|
||||
"pois": [
|
||||
{"id": "poi_conamara_chaos", "name": "Conamara Chaos", "center": [118, 328], "kind": "chaos"},
|
||||
{"id": "poi_pwyll_crater", "name": "Pwyll Crater", "center": [155, 328], "kind": "crater"},
|
||||
{"id": "poi_thera_macula", "name": "Thera Macula", "center": [145, 340], "kind": "macula"},
|
||||
{"id": "poi_tyre", "name": "Tyre", "center": [93, 357], "kind": "multi_ring"}
|
||||
]
|
||||
},
|
||||
"GJ0f-3": {
|
||||
"pois": [
|
||||
{"id": "poi_galileo_regio", "name": "Galileo Regio", "center": [90, 375], "kind": "dark_terrain"},
|
||||
{"id": "poi_uruk_sulcus", "name": "Uruk Sulcus", "center": [108, 310], "kind": "grooved"},
|
||||
{"id": "poi_gilgamesh", "name": "Gilgamesh", "center": [178, 370], "kind": "crater"}
|
||||
]
|
||||
},
|
||||
"GJ0f-4": {
|
||||
"pois": [
|
||||
{"id": "poi_valhalla", "name": "Valhalla", "center": [108, 310], "kind": "multi_ring"},
|
||||
{"id": "poi_asgard", "name": "Asgard", "center": [93, 370], "kind": "multi_ring"}
|
||||
]
|
||||
},
|
||||
"GJ0g-1": {
|
||||
"pois": [
|
||||
{"id": "poi_kraken_mare", "name": "Kraken Mare", "center": [25, 340], "kind": "methane_sea"},
|
||||
{"id": "poi_ligeia_mare", "name": "Ligeia Mare", "center": [20, 370], "kind": "methane_sea"},
|
||||
{"id": "poi_punga_mare", "name": "Punga Mare", "center": [30, 350], "kind": "methane_sea"},
|
||||
{"id": "poi_xanadu", "name": "Xanadu", "center": [117, 375], "kind": "bright_terrain"},
|
||||
{"id": "poi_shangri_la", "name": "Shangri-La", "center": [130, 310], "kind": "dune_field"}
|
||||
]
|
||||
},
|
||||
"GJ0g-2": {
|
||||
"pois": [
|
||||
{"id": "poi_tiger_stripes", "name": "Tiger Stripes", "center": [220, 256], "kind": "fracture"},
|
||||
{"id": "poi_baghdad_sulcus", "name": "Baghdad Sulcus", "center": [218, 270], "kind": "fracture"},
|
||||
{"id": "poi_samarkand_sulcus","name": "Samarkand Sulcus","center": [215, 240], "kind": "fracture"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"GJ0b": {
|
||||
"_comment": "Mercury — barren rock, tidally locked, extreme temps",
|
||||
"orbit": { "axial_tilt_deg": 0.034 },
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
|
||||
"environment": { "geothermal_flux": "low", "substrate": "silicate" }
|
||||
},
|
||||
"GJ0c": {
|
||||
"_comment": "Venus — thick sulfuric acid clouds hide the surface completely",
|
||||
"orbit": { "axial_tilt_deg": 177.4 },
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "active" },
|
||||
"physical": { "atmosphere_color": [0.92, 0.85, 0.55] },
|
||||
"environment": { "geothermal_flux": "high", "substrate": "silicate" },
|
||||
"clouds": { "enabled": true, "coverage_base": 0.95 }
|
||||
},
|
||||
"GJ0d": {
|
||||
"_comment": "Earth — use real-world data pipeline",
|
||||
"orbit": { "axial_tilt_deg": 23.44 },
|
||||
"terrain": { "land_fraction": 0.29, "polar_ice_lat": 0.85, "tectonics": "active" },
|
||||
"environment": { "hydrosphere": "ocean", "geothermal_flux": "low" },
|
||||
"clouds": { "enabled": true, "coverage_base": 0.5 }
|
||||
},
|
||||
"GJ0d-1": {
|
||||
"_comment": "Luna — barren, tidally locked",
|
||||
"orbit": { "axial_tilt_deg": 6.68 },
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
|
||||
"environment": { "geothermal_flux": "low", "substrate": "silicate" }
|
||||
},
|
||||
"GJ0e": {
|
||||
"_comment": "Mars — thin atmo, partially terraformed in lore (800 years)",
|
||||
"orbit": { "axial_tilt_deg": 25.19 },
|
||||
"terrain": { "land_fraction": 0.98, "polar_ice_lat": 0.65, "tectonics": "none" },
|
||||
"environment": { "hydrosphere": "ice", "geothermal_flux": "low", "substrate": "silicate" }
|
||||
},
|
||||
"GJ0f": {
|
||||
"_comment": "Jupiter — gas giant, Great Red Spot",
|
||||
"gas_giant": {
|
||||
"band_palette": "jovian",
|
||||
"storm_count": 2,
|
||||
"storm_max_size": 0.12
|
||||
},
|
||||
"rings": false
|
||||
},
|
||||
"GJ0f-1": {
|
||||
"_comment": "Io — volcanic moon of Jupiter, tidal heating",
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "extreme" },
|
||||
"environment": { "geothermal_flux": "high", "substrate": "silicate", "chemosynthetic": false }
|
||||
},
|
||||
"GJ0f-2": {
|
||||
"_comment": "Europa — ice moon, subsurface ocean",
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "low" },
|
||||
"environment": { "geothermal_flux": "low", "substrate": "ice" }
|
||||
},
|
||||
"GJ0f-3": {
|
||||
"_comment": "Ganymede — largest moon, ice/rock dichotomy",
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
|
||||
"environment": { "geothermal_flux": "low", "substrate": "ice" }
|
||||
},
|
||||
"GJ0f-4": {
|
||||
"_comment": "Callisto — heavily cratered ice moon",
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
|
||||
"environment": { "geothermal_flux": "low", "substrate": "ice" }
|
||||
},
|
||||
"GJ0g": {
|
||||
"_comment": "Saturn — gas giant with prominent ring system",
|
||||
"gas_giant": {
|
||||
"band_palette": "saturnian",
|
||||
"storm_count": 1,
|
||||
"storm_max_size": 0.06
|
||||
},
|
||||
"rings": {
|
||||
"enabled": true,
|
||||
"inner_radius_factor": 1.12,
|
||||
"outer_radius_factor": 2.65,
|
||||
"opacity_base": 0.68,
|
||||
"ring_color": [0.88, 0.78, 0.55]
|
||||
}
|
||||
},
|
||||
"GJ0g-1": {
|
||||
"_comment": "Titan — dense atmosphere, methane cycle",
|
||||
"terrain": { "land_fraction": 0.60, "tectonics": "low" },
|
||||
"environment": { "hydrosphere": "rivers", "geothermal_flux": "low", "substrate": "ice" }
|
||||
},
|
||||
"GJ0g-2": {
|
||||
"_comment": "Enceladus — small ice moon, geysers",
|
||||
"terrain": { "land_fraction": 1.0, "tectonics": "low" },
|
||||
"environment": { "geothermal_flux": "moderate", "substrate": "ice" }
|
||||
},
|
||||
"GJ0h": {
|
||||
"_comment": "Uranus — ice giant, extreme axial tilt",
|
||||
"orbit": { "axial_tilt_deg": 97.8 },
|
||||
"gas_giant": {
|
||||
"band_palette": "icy",
|
||||
"storm_count": 1,
|
||||
"storm_max_size": 0.04
|
||||
},
|
||||
"rings": false
|
||||
},
|
||||
"GJ0i": {
|
||||
"_comment": "Neptune — ice giant, active storms",
|
||||
"gas_giant": {
|
||||
"band_palette": "neptunian",
|
||||
"storm_count": 3,
|
||||
"storm_max_size": 0.08
|
||||
},
|
||||
"rings": false
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | High-performance materials for hulls, gate infrastructure, precision machinery. |
|
||||
|
||||
Advanced alloys are the high-specification materials required for vessel hulls, gate infrastructure, precision industrial machinery, and capital equipment designed for extreme environmental or mechanical stress. The production recipe requires 1.5t of metallic ore, 0.3t of rare minerals, and 0.2t of fusion fuel — making alloy fabrication one of three chains with direct rare minerals dependency, alongside electronics and drive core assembly.
|
||||
|
||||
The rare minerals input is the constraint that determines where advanced alloys can be produced economically. Smelting capacity is broadly distributed; rare mineral supply is not. Systems near rare mineral sources operate alloy fabrication efficiently; systems at distance pay freight premiums on the mineral input that compound through the alloy output price into the final goods that depend on them. Transport vehicles (0.8t alloy input), drive core assembly (0.8t via downstream chain), and gate components (1.0t direct) are all sensitive to alloy pricing — and all are ultimately sensitive to rare mineral geography.
|
||||
|
||||
Regional production ubiquity reflects this dependency: not every system has the mineral access to run alloy fabrication, but major manufacturing corridors typically have either local mineral production or established freight relationships with mineral-producing systems. No certification, no Compact contestation, no shadow channel. Alloys are high-value standard freight, but they're too physically heavy and too traceable by their downstream application to attract informal trade interest.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Food crops, livestock, grain, grapes. Every terraformed world produces some. |
|
||||
|
||||
Agricultural produce covers everything grown, raised, or caught before shelf-stabilization: grain, legumes, root vegetables, livestock, aquaculture, and raw harvest from viticulture or fiber-crop operations. Every terraformed world of sufficient size runs at least subsistence production. Many run surpluses. The commodity is ubiquitous because the Reach was settled with agriculture as a precondition of habitability.
|
||||
|
||||
At 5 Tractus per tonne, margins on raw produce are thin. The value is downstream. Basic grain feeds food processing; the same tonnage from a specialized biosphere world flows instead into organic compound extraction at significantly better returns per hectare. Viticulture harvest from corridor-specific terroirs (VGV valley varietals, Calloway grain) feeds branded products whose prices the generic catalog does not track. A grower on a world producing brach fiber for textile feedstock operates in a different market from the grain farmer on the same continent, even if the Commission records both as agricultural produce.
|
||||
|
||||
Perishable bulk class limits gate-transit windows. Fresh produce cannot clear multiple hops before viability degrades; frontier nodes eat locally grown staples and import processed food for shelf-stable variety. No Commission certification is required and no shadow channel operates — too bulky, too cheap, and too time-sensitive to divert profitably.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Industrial gases, reaction mass, solvents. Hazmat certification required. |
|
||||
|
||||
Chemical feedstock covers industrial gases, reactive solvents, chemical precursors, and reaction mass extracted from planetary atmospheres, gas giants, and geological deposits. The primary production path for chemicals and pharmaceuticals runs through feedstock at a 2:1 input ratio — two tonnes of feedstock yield one tonne of processed output, making feedstock availability a direct determinant of a system's chemical production capacity.
|
||||
|
||||
Commission certification is required for formal sale and transport, based on hazmat classification and dual-use concerns. This is where the Compact friction enters: Compact member systems contest Commission authority over industrial chemicals as an overreach on internal industrial policy, and certification costs — Tractus-denominated regardless of the buyer's currency zone — represent friction that accumulates at scale for Mark-primary systems. The result is a functioning shadow market for uncertified feedstock in Compact territory, priced below formal channels to offset the certification risk.
|
||||
|
||||
The organic compounds substitution route reduces feedstock dependency for chemical production on biosphere-rich worlds (0.8 output yield versus 1.0 via feedstock), which means feedstock price spikes don't cascade uniformly through the chemical chain. Systems with viable organic compound production absorb shocks; feedstock-dependent systems do not.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 3 |
|
||||
| Description | Medicines, industrial chemicals, life support consumables. Two production paths. |
|
||||
|
||||
Chemicals and pharmaceuticals is a broad intermediate category covering industrial solvents, pharmaceutical compounds, life support consumables, and specialty chemistry. Two routes produce the same output: the primary geological path (2.0t chemical feedstock + 0.5t water → 1.0t output) and the biological path (1.0t organic compounds + 0.3t water → 0.8t output). The primary path yields more per cycle; the biological path is available to biosphere-rich systems that lack chemical feedstock infrastructure.
|
||||
|
||||
The three-week panic threshold is the longest in the intermediate tier, reflecting the critical dependency on chemicals across five downstream production chains: textiles (0.2t), lattice substrate processing (0.3t), implant fabrication (0.3t), medical goods (0.4t), and habitat module assembly (0.2t). A chemicals shortage does not trigger immediate panic in any single downstream industry, but the cumulative draw means that stockpile depletion at three weeks generates anticipatory buying across multiple sectors simultaneously. By the time the shortage is visible in chemicals pricing, downstream producers have already started competing for remaining inventory.
|
||||
|
||||
Commission certification is required because the category includes pharmaceutical compounds and dual-use precursors. Compact contestation follows the same logic as chemical feedstock: certification is Tractus-denominated, and the Compact's industrial chemistry sector faces the same conversion friction on every certified purchase. The shadow market for uncertified chemicals operates primarily in Compact territory and near-frontier systems, where Commission enforcement presence is thinner and the friction cost of certification makes informal channels economically competitive. Three-week panic threshold makes uncertified supply an attractive hedge for frontier operators who cannot guarantee formal supply chain continuity.
|
||||
|
||||
@@ -16,3 +16,10 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Lattice Commission regulatory certification. Tractus-denominated. Availability inverse to shadow economy. Shadow-viable is false: you cannot shadow-market the Commission's own stamp — you shadow-market the goods that skip certification. |
|
||||
|
||||
Commission certification is the Lattice Commission's regulatory stamp — the mechanism by which lattice components, implant hardware, chemical feedstock, medical goods, transport vessels, and other regulated commodities enter formal trade. Demand is compliance-driven, not market-driven: operators procure certification because the law requires it, not because they have calculated a positive return on the cost. Inelastic elasticity follows from this — operators don't reduce their need for certification in response to price increases; they either pay or divert to shadow channels for the underlying goods.
|
||||
|
||||
The Tractus denomination of certification fees is the structural mechanism that converts Commission regulation into currency-zone politics. A Mark-primary system operator paying for chemical feedstock certification is paying in Tractus — the Assembly's currency — for the Assembly's regulatory approval to conduct commerce in their own system. The ~3% cross-currency conversion cost is the visible friction; the political objection is the invisible one. Compact member systems contest not just the fee but the legitimacy of the Commission's jurisdiction over goods moving entirely within their own territory.
|
||||
|
||||
Availability is inversely correlated with shadow economy intensity because Commission enforcement presence and shadow economy intensity are opposed forces: where the Commission maintains active presence, certification is available and formal trade operates; where shadow economy intensity is high, Commission presence is reduced, certification availability drops, and the formal/shadow boundary blurs. This is the operational meaning of `official_coverage_ratio`: high shadow intensity reduces formal certification density, which reduces the visible formal economy, which widens the gap between official and actual economic activity.
|
||||
|
||||
Shadow viable is marked false, precisely: you cannot obtain a counterfeit Commission certification stamp that carries the same legal weight as a real one — Commission records are centralized and cross-checkable. What you can do is skip certification entirely and trade the underlying goods through shadow channels. The Commission's stamp is not the contraband; it is the absence of the stamp that defines the contraband.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Daily necessities, household items, personal tech. |
|
||||
|
||||
Consumer goods is the broadest final category in the catalog, covering everything from household cookware and personal care products to light personal technology, clothing, and the durable goods that populate residential and commercial spaces. The production recipe mixes 0.3t of processed food, 0.3t of textiles, and 0.2 units of electronics — a deliberately broad input mix reflecting the category's diversity. Ubiquitous production ubiquity means consumer goods manufacturing is distributed across essentially every settled system at sufficient scale, making it the most geographically resilient final category.
|
||||
|
||||
Consumer goods demand is market-driven and unit-elastic, meaning price changes produce proportional demand responses. This is the standard commodity behavior — no panic, no regulatory complexity, no political flags. The category is interesting to traders primarily as a signal of upstream input conditions: a consumer goods price increase that outpaces general inflation suggests electronics or textiles constraints propagating forward; a consumer goods discount suggests processed food or textile surplus clearing through the manufacturing sector.
|
||||
|
||||
The 80 Tractus base price places consumer goods at the lower end of the final goods tier. The category is volume-driven — high turnover, competitive margins, geographically distributed producers. Brand differentiation (thrds garments, corridor-specific specialty items) operates above the generic catalog level, where the brand system rather than the commodity price governs margins. The generic category here is the floor of that market, not the ceiling.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Propulsion components. High-value, specialized. Double rare-minerals dependency. |
|
||||
|
||||
Drive cores are the propulsion assemblies that power commercial and industrial vessels — standardized across a size range from light freighter to heavy cargo hauler, but manufactured to precision tolerances that limit production to well-equipped industrial facilities. The recipe requires 0.4t of rare minerals (direct) and 0.8t of advanced alloys — which themselves require 0.3t of rare minerals in their production. This gives drive cores a double rare-mineral dependency: each unit produced consumes roughly 0.64t of rare minerals across both the direct and alloy-embedded inputs.
|
||||
|
||||
Elastic demand reflects the commodity's capital goods nature. Vessel operators do not replace drive cores on a fixed schedule; they replace them when degradation reaches a threshold or when procurement pricing makes early replacement economically rational. During a rare mineral price spike, operators extend core service life rather than replace at elevated prices, and demand contracts. When mineral prices normalize, deferred replacement demand returns in concentrated buying pressure. This cyclicality makes drive cores one of the more interesting freight commodities to track for timing-sensitive traders.
|
||||
|
||||
Concentrated production ubiquity means a small number of industrial systems produce essentially all commercial drive cores. Transport vehicles, freight haulers, and heavy equipment all require drive cores as inputs (0.3t, 0.5t, and 0.2t respectively), anchoring demand to the general manufacturing cycle. No shadow channel: the specifications and serial-number tracking on commercial propulsion units make informal trade impractical.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Circuits, processors, control systems. Rare mineral-dependent. Input to nearly every final good. |
|
||||
|
||||
Electronics and components cover circuit assemblies, processors, sensor arrays, control systems, and integrated modules from basic industrial controllers to precision-grade guidance hardware. The production recipe requires 0.5t of rare minerals, 0.3t of refined metals, and 0.2t of fusion fuel per unit. Rare minerals are the binding constraint — the same minerals that bottleneck alloy fabrication also bottleneck electronics, which means a rare mineral supply disruption simultaneously affects both chains and propagates into every final good that draws on either.
|
||||
|
||||
Electronics appears as an input in five of nine final production chains: heavy equipment (0.3 units), transport vehicles (0.3 units), freight haulers (0.3 units), consumer goods (0.2 units), implant hardware (0.5 units — the largest single input), habitat modules (0.3 units), and rail infrastructure (0.3 units). This breadth means electronics availability is effectively a ceiling on general manufacturing throughput. A node running low on electronics components does not slow one industry — it slows most of them.
|
||||
|
||||
Compact bulk class means electronics moves efficiently relative to its value at 120 Tractus per unit. Regional production concentrations track rare mineral geography, with electronics fabrication hubs typically located adjacent to or downstream from mineral-producing systems. No Commission certification required, no shadow market. Electronics are traceable by specification and generally too high-value for casual diversion; the shadow market interest is in the downstream products rather than the components themselves.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Performances, holo-content, cultural production. |
|
||||
|
||||
Entertainment and holo-content covers live performances, venue-based cultural events, and the consumption of holo-content in local presentation facilities. The Meridian delivers content data reach-wide, but the experience of attendance — the social context, the physical presence, the shared audience — is location-bound. A concert hall at Altmark, a gladiatorial circuit at a Compact festival venue, a holo-theatre running archival Earth content in a frontier settlement: all are priced locally against local demand and local cost structures, not against some Reach-wide market rate.
|
||||
|
||||
Common production ubiquity reflects the low capital requirement of entertainment services relative to most economic activities. Any system with population density sufficient to sustain an audience supports some entertainment sector. Quality and scale vary enormously — the holo-theatres at major station hubs operate at orders of magnitude larger scale than a frontier settlement's community hall — but the service exists almost everywhere. This makes entertainment a useful economic signal: entertainment sector health tracks discretionary income availability, and entertainment sector collapse is an early indicator of broader economic distress in a community.
|
||||
|
||||
Elastic demand means entertainment is the first casualty of genuine economic hardship. When energy costs spike and food security tightens, audiences shrink before food purchases do. The entertainment sector's price sensitivity relative to inelastic necessities makes it a leading indicator of community economic stress that most economic data series don't capture directly. No Commission involvement, no shadow channel. The content may carry political valence in some corridors — Compact cultural production explicitly reflects values the Assembly disapproves of — but the service itself does not require regulation.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Banking, credit, clearing, currency exchange. Cross-zone settlement infrastructure. |
|
||||
|
||||
Financial services covers commercial banking, corporate credit facilities, interbank clearing, currency exchange, and the cross-zone settlement infrastructure that makes multi-system trade possible. Common ubiquity means most settled systems have functional banking — the Reach cannot operate without it. The service is location-bound because banking requires local regulatory standing, local currency clearing relationships, and local credit risk assessment. A Groombridge bank can run clearing for cross-corridor transactions, but its credit assessment for a frontier mining operation relies on information that a distant institution cannot evaluate without local presence.
|
||||
|
||||
The cross-zone dimension is where financial services become strategically interesting. Mark-primary systems need financial institutions with established Tractus-Mark clearing relationships to conduct commerce with Assembly-compliant systems. The ~3% cross-currency conversion rate is the published friction; the actual cost includes settlement delays, counterparty credit risk across zone boundaries, and the political uncertainty about whether Assembly-compliant institutions will maintain correspondent relationships with Compact banks under Assembly pressure. Systems at the MARK_PRIMARY / TRACTUS_PRIMARY boundary — the MIXED zone — host the financial institutions that manage both sides of this clearing relationship, which is why those systems tend to have disproportionately large financial services sectors relative to their population.
|
||||
|
||||
No shadow channel for financial services. You can shadow-market goods; you cannot usefully shadow-market the settlement infrastructure those goods need to move. An informal bank that isn't connected to formal clearing networks cannot actually clear payments at scale. What the shadow economy uses instead of banks is Sol — the untraceable medium that requires no clearing.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Commercial cargo vessels. Demand driven by trade volume, not population. |
|
||||
|
||||
Freight haulers are commercial cargo vessels at the working scale of Reach trade: gate-capable freighters, system-run bulk carriers, and the orbital-to-surface logistics craft that move goods between planets and stations. Production requires 1.5t of refined metals, 0.5 units of drive cores, and 0.3 units of electronics — the most metal-intensive final good recipe in the catalog. Commission safety registration is required, and the Compact contests this on the same jurisdictional grounds as transport vehicle registration: Commission certification of vessels operating within Compact systems is interpreted as implicit acceptance of Assembly commercial authority.
|
||||
|
||||
Demand is driven by trade volume, not by population. A system with high trade throughput — a major transit node, a mineral-export hub, a gate junction — generates freight hauler demand in proportion to cargo flows. A system with equivalent population but low trade activity does not. This decoupling from population means freight hauler production cycles track economic activity patterns rather than demographic growth, and experienced operators monitor trade-volume signals rather than census data when positioning equipment orders.
|
||||
|
||||
Concentrated production ubiquity reflects the capital intensity of shipbuilding. Only major industrial systems with integrated metal-to-drive-core supply chains and dry dock capacity operate at commercial hauler scale. Shadow viable is false: vessels require registration for gate transit, and unregistered cargo shipping is impractical rather than merely illegal. Commission registration contestation in the Compact plays out through licensing disputes and fee non-payment rather than through shadow vessels.
|
||||
|
||||
@@ -16,3 +16,10 @@
|
||||
| Panic Threshold (weeks) | 2 |
|
||||
| Description | Deuterium/tritium refined from water at 8:1 ratio. Continuous consumption. Every system needs it. |
|
||||
|
||||
Fusion fuel is the Reach's operational energy currency. Every inhabited node consumes it continuously as a utility overhead — proportional to population and active systems — and three major industrial chains require it as a direct input: ore smelting (0.3t per tonne of refined metals), alloy fabrication (0.2t), and electronics fabrication (0.2t). The demand model is utility, not market: consumption does not adjust to price. When fuel becomes expensive, smelters still run, stations still operate, and the cost moves downstream into everything that depends on energy-intensive production.
|
||||
|
||||
The 8:1 water-to-fuel yield ratio is the structural driver of the Reach's energy geography. Eight tonnes of water refine into one tonne of fusion fuel. Water is cheap (2 Tractus/t) but the volume requirements mean that frontier refineries, which must pay elevated transport costs on water imports, produce fuel at structural cost premiums over inner corridor operations where water is locally abundant. That premium is not event-driven — it is geometric. Every hop of water transport adds to the fuel production cost, and from there to smelting throughput, alloy costs, electronics costs, and ultimately to every manufactured final good on that frontier shelf.
|
||||
|
||||
Commission certification is required for formal trade; Compact members contest this as currency-zone coercion. Certification fees are Tractus-denominated regardless of the buyer's Mark-primary zone, which means every fuel certification in the Compact zone involves a cross-currency conversion at the prevailing ~3% friction rate. This is not a regulatory dispute — it is a structural tax on Compact industrial operations, and it feeds directly into the shadow fuel market that operates throughout the west reach. Shadow fuel moves without certification; it prices below formal channels by enough to absorb the compliance risk. The two-week panic threshold means any credible supply threat triggers hoarding before the shortage materializes.
|
||||
|
||||
For nodes that are gate-energy connected, Gate Corporation's energy-over-gate service reduces utility fuel demand to roughly 0.3× baseline. Compact member systems have historically refused this service as an act of energy sovereignty — the consequence being that Compact systems run full fuel demand from their own production, but are also insulated from Gate Corporation cutoff scenarios. When a dependent inner-corridor node loses gate energy access, it is Compact surplus that absorbs the emergency demand.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Gate maintenance and construction parts. Gate Corporation monopoly. Triple rare-minerals dependency. |
|
||||
|
||||
Gate components are the maintenance and construction units for span gates — the infrastructure backbone of all inter-system commerce and communication in the Reach. Production requires 1.0t of advanced alloys, 0.5 units of drive cores, and 0.3 units of lattice substrate, creating a triple rare-minerals dependency: rare minerals appear directly in alloys (0.3t), in drive cores (0.4t direct + embedded via alloy input), and in the electronics that feed drive core production. No other final good in the catalog concentrates rare mineral dependency so completely.
|
||||
|
||||
Gate Corporation holds an effective monopoly on gate component production and maintenance. This is not a regulatory designation — it reflects the reality that the engineering specifications, manufacturing infrastructure, and technical personnel required to produce functional gate components exist in one place. Gate Corp's monopoly is self-reinforcing: the expertise to certify that gate components meet operational standards resides in the organization that produces them. Commission certification is required, and the Commission's technical standards for gate components essentially codify Gate Corp's own manufacturing specifications.
|
||||
|
||||
Compact contestation of Commission authority over gate components follows from the general Compact position on Commission jurisdiction, complicated by the fact that gate infrastructure operates within Compact systems. A Compact system disputing Commission gate component certification is in tension with Gate Corp's service contracts — the gate runs on Gate Corp's hardware, and Gate Corp's warranty and service terms require certified components. The shadow channel for gate components is thin but real: counterfeit or refurbished components that lack the certification chain occasionally reach systems where the alternative is extended gate downtime. The decision to use uncertified gate components is made under maintenance duress, not as policy.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Complete living/working units for stations and settlements. 2-5 year lead times. |
|
||||
|
||||
Habitat modules are complete, pressurized living and working units manufactured for integration into orbital stations, surface settlements, and expanding population centers. Each unit includes life support interfaces, utilities routing, structural connections, and interior finishing to habitability standards. The production recipe requires 1.2 units of structural panels, 0.3 units of electronics, and 0.2t of chemicals — with the panel input creating a direct dependency on timber or stone availability in the supply chain.
|
||||
|
||||
Lead times of 2–5 years from order to delivery are structural, not market-driven. Large station expansions require coordinated panel supply, manufacturing capacity booking, and transport scheduling well in advance of the installation date. Operators planning population expansion programs treat habitat module procurement as a multi-year infrastructure commitment rather than a commodity purchase. Systems experiencing rapid immigration pressure — frontier expansion waves, post-disruption resettlement — face severe module shortages that cannot be resolved on short notice regardless of price.
|
||||
|
||||
Settlement density across the Reach is therefore partially a function of module lead-time management. Systems that run persistent module procurement programs and maintain forward inventory position their populations for growth; systems that procure reactively face population-to-capacity mismatches during growth phases. The 2-5 year figure is why experienced colony administrators consider module order placement to be among their most consequential routine decisions.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Mining rigs, construction machinery. 1-3 year lead times. |
|
||||
|
||||
Heavy equipment covers the large-scale mechanical systems used in extraction, construction, and industrial operations: mining rigs, ore processors, planetary dozers, drilling platforms, construction cranes, and the capital machinery of expansion-phase development. Production requires 1.0t of refined metals, 0.3 units of electronics, and 0.2 units of drive cores per unit — positioning heavy equipment as a capital goods category with direct drive core dependency.
|
||||
|
||||
Elastic demand reflects the capital investment cycle. Mining and construction operators hold equipment orders against expected project demands and defer during economic contractions or price spikes. A 1–3 year lead time from order to delivery means demand signals in heavy equipment pricing represent commitments made one to three years ago, not current conditions. Active frontier development systems generate consistent heavy equipment demand; established core systems replace equipment on longer replacement cycles.
|
||||
|
||||
The economic significance of heavy equipment exceeds its direct commodity price. Equipment throughput determines extraction rates, construction timelines, and the physical pace of expansion — the economic output of a mining operation scales with its equipment capacity, and delayed equipment procurement delays the ore throughput that feeds downstream manufacturing. Stalownia is the canonical Reach manufacturer in this category; their large rigs carry 1–3 year lead times even in normal conditions, and procurement priority is a commercial relationship as much as a price negotiation.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Corridor taverns, station cantinas, Talbreu taphouses. Cultural dining reflecting local heritage and transit-corridor traditions. |
|
||||
|
||||
Fine dining and hospitality encompasses the full range of prepared food service and residential accommodation, from transit corridor cantinas to the Talbreu taphouse circuit and the prestige restaurant culture of major hub stations. The service is explicitly location-bound: Ostmark cuisine prepared at Ostmark, with Ostmark cultural context and the social network that makes a specific establishment meaningful, is not reproducible elsewhere. A station cantina in the outer west reach serves as both provisioner and community infrastructure — the social hub where transit workers, merchants, and locals negotiate the informal economy that formal records don't capture.
|
||||
|
||||
Regional production ubiquity reflects the commodity's dual dependency: good hospitality requires both the skill and cultural context to prepare it, and reliable access to the agricultural produce and processed food inputs that go into the service. Frontier systems maintain basic provisions but rarely develop the ingredient diversity that supports serious hospitality culture until the settlement matures. Major transit nodes and corridor junctions develop hospitality sectors disproportionate to their populations because transit populations are willing to pay for quality in transient contexts.
|
||||
|
||||
The 60 Tractus base price positions fine dining above entertainment as a discretionary luxury but below tourism as a considered destination investment. Elastic demand means it contracts with income; it also expands notably during transit booms and economic growth cycles. The cultural character of hospitality in specific corridors — the Germanic-Scandinavian cooking of west reach settlements, the east reach fusion traditions, the Sol-nostalgic cuisine that clusters around stations with significant Unbound communities — is legible in the commodity pricing but not captured in the generic catalog figure.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Neural interfaces, lattice components. THE Compact political flashpoint. D-037 adjacent. |
|
||||
|
||||
Implant hardware covers neural interface assemblies, lattice tier upgrade components, peripheral neural modules, and the physical substrate of the Commission's certified lattice ecosystem. Production requires 0.5 units of electronics, 0.3t of chemicals, and 0.4 units of lattice substrate — the highest electronics input of any final good in the catalog, reflecting the processing density required for neural-grade signal fidelity. Commission certification is not optional; it is the difference between a certified medical device and contraband.
|
||||
|
||||
The Compact's contestation of Commission authority over implant hardware is the sharpest political friction point in the commodity catalog. Compact member systems interpret Commission certification requirements as jurisdictional overreach that gives the Assembly a structural mechanism to control their citizens' neural autonomy through the hardware supply chain. Certification fees are Tractus-denominated and non-negotiable. Every certified implant purchased in a Mark-primary system pays cross-currency conversion on top of the certification fee — a structural tax on neural identity that the Compact has consistently refused to normalize.
|
||||
|
||||
The shadow market for uncertified implants is the canonical contraband stream of the Reach. Unlicensed lattice components are not conceptually different from certified ones; they simply lack the Commission audit trail that would tie the hardware to a registered citizen identity. Shadow-viable implants trade in deep-discount channels, primarily in Compact territory and frontier systems where Commission enforcement presence is intermittent. The demand comes from: citizens priced out of certified hardware, individuals seeking capabilities beyond licensed tiers, and operators who want neural infrastructure that is not on the Commission's registry. All three groups exist in every corridor at varying intensity.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Cargo, liability, corporate risk management. Affects trade volume through risk pricing. |
|
||||
|
||||
Insurance and risk management services cover cargo underwriting, commercial liability policies, corporate risk transfer, and the actuarial infrastructure that enables large-scale trade and investment across systems with varying political stability. These are location-bound services: an insurer operating at Altmark is pricing risk for the west-reach corridor, drawing on local loss data, local legal frameworks, and local settlement capacity. That expertise cannot be freighted to another system — a Compact insurer providing coverage for inner-corridor cargo is operating at an information disadvantage that the price reflects.
|
||||
|
||||
The critical economic function is enabling trade volume. Insurance does not add to GDP in a conventional sense, but uninsured freight is freight that either doesn't move or moves at risk pricing that constrains volume. High-insurance-cost corridors generate measurably lower freight volumes than equivalent corridors with competitive insurance markets. This is the mechanism by which `shadow_economy_intensity` interacts with formal trade: shadow economy expansion degrades insurers' ability to price risk accurately (they can't see the full economic picture), which raises premiums, which suppresses formal trade volume, which is the `official_coverage_ratio` degrading in real time.
|
||||
|
||||
Regional production ubiquity means not every system has competitive insurance depth. Frontier systems often operate with single-insurer coverage or import coverage from corridor hubs at a distance premium. No certification required, no shadow channel — insurance is location-bound professional services, and the contractual framework that makes an insurance policy valuable requires functioning legal infrastructure anyway.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Crystalline substrates for neural lattice fabrication and gate optics. East reach anchor. Strategic material. |
|
||||
|
||||
Lattice-grade material is a class of crystalline substrates with the purity and structural coherence required for neural lattice wafer fabrication and gate optic components. Production requires geological conditions not present across most of the Reach — a small number of east reach systems hold essentially all known commercial deposits. Monopolistic ubiquity is not market language: there is no substitute input for lattice substrate processing, and the east reach anchor systems holding those deposits know it.
|
||||
|
||||
Commission certification is required for all formal transactions. Lattice-grade material is a dual-use precursor — the same crystalline substrates feed licensed implant fabrication and unlicensed modification. This is why shadow viability is marked yes despite Commission oversight: the formal market controls supply but cannot fully suppress demand from unlicensed fabricators, particularly in corridors where the Commission's operational presence is intermittent. Uncertified lattice-grade material commands a substantial premium in shadow channels because the certification process provides traceability that buyers in those channels are specifically avoiding.
|
||||
|
||||
The production chain is short: 1.0t of lattice-grade material combined with 0.3t of chemicals yields 1.0t of lattice substrate. The simplicity of that recipe makes the material's scarcity the dominant cost driver — there is no processing efficiency to gain, only sourcing discipline to maintain. Gate component manufacturers, implant fabricators, and lattice substrate processors all operate under the constraint that their primary input sources are geographically specific and Commission-monitored. Supply disruptions from east reach propagate directly into implant hardware and gate component pricing within two to three production cycles.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Processed neural lattice wafers and gate optics. From lattice-grade material. |
|
||||
|
||||
Lattice substrate is the processed form of lattice-grade material: precision-cleaned crystalline wafers and optically finished gate components ready for end-stage fabrication. The processing recipe is simple — 1.0t of lattice-grade material plus 0.3t of chemicals yields 1.0t of substrate — but the output requires Commission certification for legal sale, and the production is concentrated in systems adjacent to east reach lattice-grade deposits. Moving substrate legally means moving it with its certification chain intact.
|
||||
|
||||
The shadow market exists because of what lattice substrate feeds: implant hardware fabrication consumes 0.4 units per output, and uncertified implants require uncertified substrate. Commission certification provides the traceability that distinguishes a licensed neural interface from contraband. Any fabricator operating outside Commission oversight needs a substrate source that doesn't carry a traceable certification number — hence the shadow channel, which sources material from either uncertified processing operations or certified stock that has been diverted after purchase and before the final audit trail would record the end use.
|
||||
|
||||
Gate component assembly also requires 0.3 units of lattice substrate per gate unit — a separate demand stream from the implant channel, operating entirely in the formal economy because Gate Corporation has no incentive to circumvent its own supply chain audits. The substrate market is therefore split between two fundamentally different demand profiles: the traceable, regulated gate manufacturing sector and the partially-shadow implant fabrication sector.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Contract enforcement, dispute resolution, commercial law. |
|
||||
|
||||
Legal services covers commercial contract enforcement, trade dispute resolution, licensing and regulatory compliance work, corporate structuring, and the transactional legal infrastructure that supports all formal economic activity. Inelastic demand reflects that legal services are consumed when disputes arise and contracts are formed — neither of which scales back significantly when prices rise. Common production ubiquity means most settled systems maintain some legal services capacity, though depth and quality vary considerably between a major commercial hub and a frontier outpost.
|
||||
|
||||
The commodity's economic role is as an enabling infrastructure for everything else. Formal trade requires enforceable contracts. Insurance contracts require legal standing. Commission certification challenges require legal representation. A system with functional legal services can support higher-value economic activity than one without, because counterparties cannot rely on contract enforceability in the absence of competent legal institutions. This is the mechanism by which governance quality feeds into economic productivity — not directly measurable in the simulation's commodity framework, but present in the effective functioning of every market transaction.
|
||||
|
||||
No Commission certification, no shadow channel. Legal services require qualified personnel operating within a recognized legal framework — they are inherently formal sector, and an "informal" legal service is simply a contract that neither party has meaningful recourse to enforce. The service is location-bound by jurisdiction: Compact legal services are authoritative within Compact systems and largely unenforceable outside them, which is precisely the point.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Pharmaceuticals, surgical equipment, diagnostics, re-embodiment supporting hardware. |
|
||||
|
||||
Medical goods covers certified pharmaceuticals, surgical equipment, diagnostic hardware, and the supporting physical components for licensed re-embodiment procedures. Production requires 0.4t of chemicals, 0.3 units of electronics, and 0.2t of advanced alloys — inputs from across the technology chain. Commission certification is required for formal distribution because the category includes pharmaceutical compounds and re-embodiment hardware with direct implications for patient safety and lattice compliance.
|
||||
|
||||
Compact contestation of Commission authority over medical goods is one of the more politically charged disputes in the certification framework. Commission oversight of re-embodiment hardware is interpreted by Compact member systems as an attempt to extend Commission jurisdiction over their citizens' neural identity infrastructure through the goods supply chain. The Tractus-denominated certification fee adds currency friction on top of the jurisdictional grievance. The result is a functioning shadow market in medical goods throughout the Compact zone — not driven by criminal demand but by communities that have collectively decided that Commission certification is not a legitimate gate on their medical autonomy.
|
||||
|
||||
Medical goods without certification cannot be distinguished from certified goods by physical inspection. The shadow market prices them at a discount that reflects the legal risk to the buyer, not any quality difference. In Compact territory, that discount is narrow — the political cover provided by Compact non-compliance with Commission authority reduces buyer risk substantially. In inner corridor systems, the legal exposure is larger and the shadow discount is correspondingly deeper.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Healthcare, neural backup, licensed re-embodiment. Unlicensed variant is canonical contraband. |
|
||||
|
||||
Medical and re-embodiment services covers general healthcare, neural lattice backup procedures, and the full stack of licensed re-embodiment: the neural imprint capture, body preparation, continuity verification, and identity attestation that constitutes a legal re-embodiment under Commission standards. These are location-bound services — re-embodiment requires certified facilities, certified personnel, certified equipment, and a Commission-recognized procedural record. A body prepared in one system cannot be considered continuously certified if the procedure crosses jurisdictions without Commission tracking.
|
||||
|
||||
Commission certification is mandatory because re-embodiment is identity-critical infrastructure. An uncertified re-embodiment is, under Assembly law, not a continuation of the original person's legal identity. The downstream consequences — property rights, contract obligations, criminal record, familial status — make the certification question existential rather than regulatory. This is why unlicensed re-embodiment is canonical contraband: the buyer is not purchasing a cheaper service, they are purchasing a service that the Commission will not recognize as valid, with all the legal exposure that entails.
|
||||
|
||||
Compact contestation follows from the jurisdiction dispute over lattice regulation and the particular sensitivity of re-embodiment to Commission authority. Compact member systems have passed internal legislation recognizing re-embodiment procedures performed under their own governance frameworks as legally valid. The result is a two-tier system: re-embodiment certified by both Compact frameworks and Commission standards is recognized everywhere; re-embodiment certified only under Compact law is recognized in Compact systems and refused or contested in Assembly-compliant systems. The shadow market for medical services in Compact territory is therefore less criminal than it is an extension of the political dispute — communities offering re-embodiment under their own authority rather than applying for Commission certification they consider illegitimate.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Ferrous and non-ferrous ore from planetary mines and asteroid operations. |
|
||||
|
||||
Metallic ore is the base input for the Reach's entire metals economy: ferrous ores for structural steel, non-ferrous for copper wire, aluminum, and specialty metals for precision components. Common availability across settled space means most mid-corridor systems have at least one active mining operation. The commodity becomes interesting at the frontier, where terrestrial ore bodies may be unexploited and asteroid belt access varies enormously with system topology.
|
||||
|
||||
Smelting consumes 2t of ore to produce 1t of refined metals — a 2:1 drawdown that makes the ore-to-metals ratio a standard measure of a system's metal production efficiency. The process also requires 0.3t of fusion fuel per output tonne, which means smelting throughput is an energy cost story as much as an ore availability story. Frontier smelters pay more to run the same recipe because fuel costs are structurally elevated.
|
||||
|
||||
Alloy fabrication draws on both metallic ore (1.5t) and rare minerals (0.3t), making ore an input to two different metal chains. This dual dependency means a single ore shortage cascades into both refined metals and advanced alloys simultaneously. No certification required, no shadow channel. Ore moves as bulk freight and its value accumulates in the processing step.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Biochemicals, pharmaceutical precursors, biological feedstock. Requires complex biosphere. |
|
||||
|
||||
Organic compounds require a functioning complex biosphere to produce in commercial volumes — not just agriculture, but a mature ecological system capable of yielding biochemicals, enzyme precursors, complex lipids, and the biologically-derived feedstocks that industrial chemistry cannot economically synthesize from mineral sources. This restricts production to older terraformed worlds and naturally habitable planets with sufficient ecological depth. Regional ubiquity reflects this: not rare, but not universal.
|
||||
|
||||
The commodity feeds two production chains. In textile fabrication, organic compounds (0.8t) combined with chemicals (0.2t) yield textiles and composites — the only route for brach fiber processing, which requires biological precursors that chemical synthesis cannot replicate. In chemical production, organic compounds provide the substitution route: a biological pathway yielding 0.8t of chemicals per 1.0t input versus 1.0t from the feedstock route. Lower yield, but available to worlds with functioning biospheres and no chemical feedstock infrastructure.
|
||||
|
||||
At 35 Tractus per tonne, organic compounds command a premium over stone and metallic ore but remain well below the high-value raw materials. The premium reflects the biosphere requirement — geological extraction scales with equipment investment, but biosphere extraction scales with ecological health over centuries. Braemar is the canonical example: brach fiber output is capped by herd biology and habitat, not by investment.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 2 |
|
||||
| Description | Shelf-stable, transportable food products. |
|
||||
|
||||
Processed food is agricultural produce run through preservation, packaging, and shelf-stabilization. The recipe requires 1.5t of agricultural produce and 0.2t of water per tonne of output — a simple, energy-light process that every settled world with surplus agriculture runs continuously. The output is transportable through gates without viability loss, which is precisely the point: raw produce cannot clear multiple hops, but processed food can move Reach-wide on standard freight schedules.
|
||||
|
||||
The two-week panic threshold is lower than it looks. Most inhabited nodes maintain 2–6 weeks of processed food stock as standard operational reserves. A supply disruption triggering hoarding behavior compresses that buffer quickly, particularly in high-population orbital stations where local food production is negligible and processed food is the primary caloric supply. Stations are more exposed than planetary surfaces; a gate blockage affecting a station's primary food route becomes a crisis in days rather than weeks.
|
||||
|
||||
Ubiquitous production means processed food rarely generates price signals worth chasing on its own. The trading interest is in agricultural production disruptions upstream — a bad harvest on a surplus world ripples into processed food availability two to three production cycles later, and by then the price signal has already moved. Follow the agricultural conditions, not the shelf-stable output.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Planetary transit systems, trains, intra-system transport networks. |
|
||||
|
||||
Rail infrastructure covers the manufactured components of planetary and intra-system transit networks: rolling stock, track sections, station structures, signaling systems, and the specialized industrial rail used in mining operations and freight corridors. The production recipe is metals-heavy: 1.0t of refined metals, 1.5 units of structural panels, and 0.3 units of electronics per output unit. The panel-intensive nature of station and trackway construction makes rail infrastructure particularly sensitive to timber and stone supply conditions upstream.
|
||||
|
||||
Demand is driven by economic development cycles rather than population. A system growing in population needs housing (habitat modules); a system growing in economic activity needs freight and passenger transport. These are correlated but not identical — a primarily extractive system with high freight demand may run heavy industrial rail while keeping residential infrastructure minimal. Transit investment decisions reflect governance priorities as much as raw economic signals, which means rail infrastructure pricing can diverge from the general construction sector even when habitat modules and structural panels are moving in the same direction.
|
||||
|
||||
Regional ubiquity positions rail infrastructure between the broadly distributed consumer goods sector and the concentrated production of drive cores or lattice substrate. Corridor-specific manufacturing hubs specialize in rail components for their geography's terrain and climate requirements, but the fundamentals of steel track, electrified rolling stock, and pre-fabricated station sections are common enough to sustain production at moderate scale across most mid-corridor systems.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Exotic crystals, rare earths. Scarce, location-specific. Critical bottleneck for high-tech manufacturing. |
|
||||
|
||||
Rare minerals — exotic crystals, heavy rare earths, actinide-adjacent compounds, and precision-grade inorganic substrates — are the single most important supply-chain chokepoint in high-technology manufacturing. Electronics fabrication requires 0.5t per output unit; alloy fabrication requires 0.3t; drive core assembly requires 0.4t directly, plus additional mineral content in the alloy input. A rare minerals shortage cascades simultaneously into electronics, advanced alloys, and drive cores — and from there into nearly every final good that depends on those intermediates.
|
||||
|
||||
Production is concentrated, not distributed. Rare mineral deposits require specific geological conditions that most terrestrial and asteroid environments do not provide. The systems that have them hold structural economic advantages in the technology supply chain that cannot be replicated by investment alone — the geology is either there or it is not. This concentrates production in a relatively small number of systems, and the trade routes that move rare minerals outward from those systems are among the most reliably profitable freight corridors in the Reach.
|
||||
|
||||
No Commission certification required, no shadow channel. At 80 Tractus per tonne and precision bulk class, rare minerals move as high-value compact freight, not in bulk. The absence of a shadow market reflects both the difficulty of separating extraction from certification-free sale and the fact that the bottleneck commodity in three separate production chains draws too much scrutiny for informal trade to operate at scale.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Structural steel, copper wire, aluminum. Backbone of construction and manufacturing. |
|
||||
|
||||
Refined metals are the basic structural material of every construction and manufacturing sector in the Reach. Smelting reduces two tonnes of metallic ore plus 0.3t of fusion fuel to one tonne of output — structural steel, rolled aluminum, copper wire, and the general-purpose metal stock that goes into buildings, vehicles, equipment, and infrastructure. Common production ubiquity reflects the fact that ore is common and smelting infrastructure is well-distributed across the Reach.
|
||||
|
||||
The commodity feeds four final production chains either directly or as a critical input: freight hauler construction (1.5t), heavy equipment assembly (1.0t), rail infrastructure construction (1.0t), and consumer goods (indirectly, via textiles and panel components). Inelastic elasticity means demand holds regardless of price fluctuations — manufacturers don't stop building because steel costs more, they pass the cost forward. The absence of a panic threshold reflects durability; refined metals stockpile without degradation and buyers hold inventory against supply variability without triggering hoarding dynamics.
|
||||
|
||||
Smelting throughput is energy-constrained. A frontier smelter running at 30 Tractus/t output pays more than an inner corridor smelter to produce the same tonne, because fuel input costs are structurally elevated by water transport economics. This means refined metals prices quietly encode the fuel cost geography of wherever they were produced — metal from deep frontier carries a structural premium over the same specification produced at hop 2.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Construction stone, ceramics feedstock. Substitution route for structural panels. |
|
||||
|
||||
Stone and aggregate are the fallback input for structural panel production on worlds without viable forestry. Quarried stone, gravel, crushed rock, and volcanic aggregate feed both direct construction and the ceramic processing sector. Common availability means most settled worlds can source locally — the commodity rarely moves long distances unless a large construction program outpaces local quarry capacity.
|
||||
|
||||
The critical economic role is as a substitution route. Timber-deficient worlds or settlements without managed forest access use stone-based panel fabrication instead. Stone panels yield 0.8 units per 1.5t input versus timber's 1.0 per 1.0t — lower efficiency, different source. For systems with abundant quarry operations and no accessible forests, the stone route is economically rational regardless of the yield gap.
|
||||
|
||||
At 8 Tractus per tonne, stone is marginally more expensive than water and far cheaper than any processed good. The commodity generates interest only at scale — major infrastructure projects, rapid settlement expansion, or station construction programs draw enough volume to create corridor price signals. No certification, no political flags, no shadow channel.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Pre-fabricated building components. Two production paths: timber or stone. |
|
||||
|
||||
Structural panels are pre-fabricated building elements — walls, floors, ceiling cassettes, modular partitioning, pressurized station sections — manufactured at scale for assembly on-site. Two production routes feed the same output: the timber route (1.0t timber + 0.5t refined metals → 1.0 unit) and the stone route (1.5t stone + 0.5t refined metals → 0.8 units). Timber yields more per input cycle; stone yields less but uses a different and generally more available raw. Regional ubiquity reflects that panel manufacturing requires stable raw supply chains, which not every system maintains.
|
||||
|
||||
The two-route structure is the commodity's most important economic feature. A timber shortage on a world running the timber route forces either a switch to the less efficient stone route or import of finished panels from neighbors. A stone shortage on a stone-route world rarely causes disruption because stone is more geographically common than managed forest. Systems that have both routes available carry structural resilience in their construction sector that single-route systems lack.
|
||||
|
||||
Panels are a direct input to habitat modules (1.2 units) and rail infrastructure (1.5 units), making panel availability the gating factor for settlement expansion and transit development. Station construction programs in particular draw on panels intensively — station sections are modular by necessity, and each new habitation ring is largely a panel assembly problem. Disruptions in timber-primary corridors cascade into habitat module lead times, which cascade into settlement capacity expansion.
|
||||
|
||||
@@ -16,3 +16,8 @@
|
||||
| Panic Threshold (weeks) | 0 |
|
||||
| Description | Clothing, technical fabrics, composite materials. |
|
||||
|
||||
Textiles and composites cover the full spectrum from basic clothing fiber to technical aerospace composites, carbon-weave structural reinforcement, and the specialty biological fabrics — brach fiber among them — that carry a cultural premium in specific corridor markets. Production requires 0.8t of organic compounds and 0.2t of chemicals per output tonne. The organic compounds dependency means textile production is effectively a biosphere-dependent industry: worlds without viable complex ecology cannot run it independently and must import.
|
||||
|
||||
The commodity is a direct input to consumer goods (0.3t per unit), making textiles a quiet but consistent component of the broadest demand category in the simulation. Consumer goods demand is ubiquitous and market-driven; a textiles shortage ripples into consumer goods production at every manufacturing node that depends on it. Regional ubiquity means this effect is not uniform — outer corridor nodes with limited local textile production are more exposed to import disruptions than inner-corridor manufacturing hubs.
|
||||
|
||||
At 50 Tractus per tonne, textiles carry a real price premium over the base raw materials that go into them, reflecting the processing complexity and the biosphere requirement. Specialty textile streams — brach fiber from Braemar, technical composites for shipbuilding — command prices above the generic catalog figure, but the simulation tracks this category at the generic level. No certification, no shadow channel. Technical composites occasionally attract attention for dual-use concerns, but not enough to generate a formal shadow market.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user