Compare commits

...
15 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 2319c6f6ca chore(meta): release v0.1.34
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:21:52 +02:00
jpmschweitzerandClaude Opus 4.6 e0d2ea32a5 fix(decisions): address PR #126 review — cross-refs, index, arithmetic
- Fix D-189 cross-ref: D-131 → content.md (was scope.md), fix anchor slug
- Fix D-191 cross-refs: D-093, D-095, D-138 → content.md (were architecture.md)
- Fix architecture.md footer counter: 52 → 53
- Fix D-190 ratio arithmetic: 1:100 → 1:80, 1:20-50 → 1:16-40
- Merge Destilaria Confluência/Lento into single row, update corp count ~28 → ~27
- Fix platform shorthand → platform_catalogue in D-189 §11
- Add D-189, D-190, D-191 to decisions/README.md index

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:06:23 +02:00
jpmschweitzerandClaude Opus 4.6 b69645d35c docs(decisions): D-189 brand layer architecture, D-190 volume calibration, D-191 Atlas Phase 3 scope
Sprint 34 planning workshop output:
- D-189: Brand layer architecture — 8 categories, administered pricing model,
  halo/volume tiers, 10K minor brands, Gemma 2 naming, corp tax/GDP, verb ladder
- D-190: Brand volume calibration — population-relative scale for ~80B Reach
- D-191: Atlas of the Reach Phase 3 — terrain-aware city placement, sequential
  settlement growth, Gemma 2 geographic naming, 9 MVP overlays, heightmap viewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:04:41 +02:00
jpmschweitzerandClaude Opus 4.6 a9bd3ca408 Merge remote-tracking branch 'origin/sprint-34/client'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:19:52 +02:00
jpmschweitzerandClaude Opus 4.6 4d4dc2014d fix(ui): rebind economics panel toggle from E to N (#124 review R2)
E is claimed by InputMap "interact" action — InputMapper consumes
it before _unhandled_key_input. N is free, adjacent to M (star map),
reads as "Numbers" for the economics monitor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 14:25:07 +02:00
jpmschweitzer 08297a9cae Merge remote-tracking branch 'origin/sprint-34/copy' 2026-04-10 14:19:09 +02:00
jpmschweitzerandClaude Opus 4.6 ac6c9dd2cd style(simulation): cargo fmt economy.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:07:14 +02:00
jpmschweitzerandClaude Opus 4.6 1498115aba fix(simulation): address PR #125 review — tick truncation, ordering, perf, protocol test
- Widen EventPort tick methods from u32 to u64 (prevents overflow)
- Add is_identity() guard on hot-path String allocation in modifiers
- Replace Vec::remove(0) with VecDeque::pop_front() in price history
- Add .after(tick_economy_simulation) ordering for debug commands
- Fix stale PROTOCOL_VERSION assertion (20 → 21) in serialization test
- Add D-181 Phase 2 visibility scope comment on serve_econ_state_query
- Eliminate double lookup in rebuild_signals via single-pass extraction
- Track economy seed TODO with backlog ticket reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:06:54 +02:00
jpmschweitzerandClaude Opus 4.6 28d95c23a8 fix(ui): address PR #124 review — navigation, error handling, cleanup
- Economics panel: replace dead _gui_input LEFT/RIGHT with public
  navigate() method, wire [ ] keys in main.gd (avoids movement
  key conflict, fixes focus_mode=NONE issue)
- Debug console: add explicit effect guard in econ inject no-commodity
  branch so invalid effects don't fall to commodity-form error
- Snapshot consumer: null-clear GameState.economy_snapshot after
  consuming (matches one-shot consumer invariant)
- Star map: remove duplicate doc comment above set_insert_active()
- Generate script: remove stale comment, dead _WORKTREE_PARENT var,
  dead field extraction in parse_wiki_index, add try/except around
  DB queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 14:03:49 +02:00
jpmschweitzerandClaude Opus 4.6 f41ab6c8e8 fix(content): address PR #123 round 2 — Founder terminology, names
Fix 3 issues from round 2 review:

- ovh_arc_003: "Gatebuilder" → "Founder structure" (canonical term)
- ovh_ror_002: Subordinate → Colleague (peers, not hierarchy)
- ovh_mfg_001: "Orien" → "Krev" (too close to Earth constellation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 14:03:41 +02:00
jpmschweitzerandClaude Opus 4.6 f301fdecc7 fix(content): address PR #123 review — registers, names, patterns
Fix 15 issues from Hoshe, Paula, and Miri review:

QA: correct changelog count (78 new, not 94), fix "attach" → "attaché"
in ovh_dip_002.

Narrative: fix register mismatches (ovh_rag_002 Social→Gossip,
ovh_ctx_002 Social→Gossip, ovh_med_003 Gossip→Work), fix inverted
authority dynamic in ovh_hos_002, add progression to ovh_evn_002 third
turn, replace frontier_medic/liaison with ranger/settlement_administrator
in ovh_dis_002, break extraction/industrial template pattern by
rewriting ovh_mfg_001 (social/family) and ovh_epl_001 (social/plans).

World consistency: replace Earth-ethnic names (Kessler-Dunn→Vasara-Lenn,
Kovacs→Tanev, Harlan→Sareth, Henwick→Merata), replace unanchored
location (Old Pemmar→Old Tarassa), replace lore-colliding name
(Calloway→Vedara).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:37:47 +02:00
jpmschweitzerandClaude Sonnet 4.6 77232434f4 style(simulation): cargo fmt + fix clippy warnings after economics integration
- `economy.rs`: fix empty_line_after_doc_comments (section ordering),
  use `is_multiple_of` for ECON_TICK_RATE check
- `debug.rs`, `input.rs`, `mod.rs`: rustfmt import ordering + indentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:22:47 +02:00
jpmschweitzerandClaude Sonnet 4.6 5f4139bc9e feat(simulation): implement economics integration sprint — #810 #821 #822 #823
Implements the full D-180/D-181 economics pipeline:

**#810 — Event input port (D-180)**
- Add EconEvent struct with Target/Effect/Duration/Visibility variants
- Implement EventPort as typed input queue for external disruptions
- Apply events in simulation step; D-179 Test 3 now uses real shock injection

**#821 — Integrate econ-sim into server tick loop**
- Extract econ-sim as library crate (lib.rs + sim.rs, Cargo.toml [lib] section)
- Add Simulation stateful runner; step() advances one economy tick
- Add EconSimResource, EconStateResource (7 D-181 signals), tick_economy_simulation
- Economy loads once at startup; graceful no-op when systems.db absent
- Server advances economy 1 tick per 10 game ticks (D-031)

**#822 — Expose economy state over IPC bridge**
- Protocol version 20 → 21
- Add EconomySnapshot, EconNodeSnapshot wire types
- Add EconStateQuery PlayerAction variant; response in economy_snapshot field
- Add EconQueryBuffer resource + serve_econ_state_query system

**#823 — Economics debug commands**
- Add InjectEconEvent, SetEconParam, GetEconState to DebugCommandKind
- Add EconDebugEffect, EconParamKind enums
- SetEconParam mutates α/β at runtime (α/β promoted to pub const + Simulation fields)
- ALPHA and BETA constants threaded through step_inner/trade_step signatures

All 1147 unit tests pass; zero warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:13:52 +02:00
jpmschweitzerandClaude Opus 4.6 38d4664559 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:54:26 +02:00
jpmschweitzerandClaude Opus 4.6 26a74b2bfb feat(content): author overheard conversations for all 31 zone types (#695)
Expand overheard.ron from 5 zone types (16 conversations) to full
coverage of all 31 zone types with 94 new conversations. Each zone
type has 2-4 role-pair conversations following D-078 occlusion-
resilient authoring rules. Conversations carry investigative
knowledge payloads where appropriate — institutional cover-ups,
manifest discrepancies, suppressed inspections, and cultural signals
players can follow.

Zone types added: administrative_civil, administrative_judicial,
archaeological_site, commercial_market, commercial_transit,
detention_facility, diplomatic_elite, entertainment_venue,
extraction_platform, extraction_space, extraction_surface,
industrial_manufacturing, industrial_processing, medical_facility,
military_garrison, port_fishing, port_maritime, port_space,
port_surface, research_station, residential_surface, rural_aquaculture,
rural_orbital, rural_pastoral, security_checkpoint, wilderness_frontier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:54:12 +02:00
34 changed files with 3310 additions and 434 deletions
+8 -1
View File
@@ -6,10 +6,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
## [v0.1.34] — 2026-04-10
### Added
- Star map info panel shows system population and GDP when data is available (#785)
- Economics simulation integrated into server tick loop — econ-sim library crate, D-180 event port, D-181 7-signal vocabulary, IPC bridge (protocol v21), debug commands (#810, #821, #822, #823)
- Economics Monitor implant panel — system selector, 6-commodity price table with trend arrows, GDP strip (#824)
- Debug console `econ inject`, `econ param`, `econ inspect` commands for runtime economics manipulation (#825)
- Star map info panel shows system population and GDP when data is available (#785)
- Overheard conversations for all 31 zone types (was 5): 78 new ambient dialogue entries (94 total) with D-078 occlusion-resilient authoring, investigative knowledge payloads, and culture-neutral role-pair conversations (#695)
- D-189 brand layer architecture — administered pricing, halo/volume tiers, 8 brand categories, corp tax/GDP
- D-190 brand volume calibration — population-relative scale for ~80B Reach
- D-191 Atlas of the Reach Phase 3 scope — sequential settlement growth, Gemma 2 naming pipeline, 9 MVP overlays
## [v0.1.33] — 2026-04-08
+10 -2
View File
@@ -172,10 +172,18 @@ func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey and event.keycode == KEY_M:
if star_map:
star_map.toggle_visible()
elif event is InputEventKey and event.keycode == KEY_E:
# #824: E — toggle Economics Monitor implant panel
elif event is InputEventKey and event.keycode == KEY_N:
# #824: N — toggle Economics Monitor implant panel (E is bound to interact)
if economics_panel:
economics_panel.toggle_visible()
elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT:
# #824: [ — cycle economics panel system selector backward
if economics_panel and HudGroups.is_app_active("implant/economics"):
economics_panel.navigate(-1)
elif event is InputEventKey and event.keycode == KEY_BRACKETRIGHT:
# #824: ] — cycle economics panel system selector forward
if economics_panel and HudGroups.is_app_active("implant/economics"):
economics_panel.navigate(1)
func _process(delta: float) -> void:
+1
View File
@@ -183,6 +183,7 @@ func consume_economy_snapshot() -> void:
return
if economics_panel.has_method("receive_economy_data"):
economics_panel.receive_economy_data(GameState.economy_snapshot)
GameState.economy_snapshot = null
# #174: Consume examine result — show overlay when server sends character-filtered observation.
+6
View File
@@ -287,6 +287,12 @@ func _econ_inject(parts: Array) -> void:
magnitude_str = args[2]
if args.size() >= 4:
ticks_str = args[3]
elif args.size() == 3:
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
_append_text(
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
)
return
else:
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
commodity_id = args[1]
+10 -25
View File
@@ -167,10 +167,11 @@ func _load_system_list() -> void:
var sid: String = node.get("system_id", "")
if not sid.is_empty():
_systems.append(node)
_systems.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var na: String = a.get("proper_name", a.get("system_id", ""))
var nb: String = b.get("proper_name", b.get("system_id", ""))
return na < nb
_systems.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
var na: String = a.get("proper_name", a.get("system_id", ""))
var nb: String = b.get("proper_name", b.get("system_id", ""))
return na < nb
)
if not _systems.is_empty():
selected_system = _systems[0].get("system_id", "")
@@ -256,7 +257,7 @@ func _rebuild_panel() -> void:
_panel.add_component(_placeholder_notice)
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("◄ ► select · E close"))
_panel.add_component(ImplantTextBlock.new("[ ] select system · N close"))
func _current_node() -> Dictionary:
@@ -304,27 +305,11 @@ func _on_economy_data_updated(system_id: String, data: Dictionary) -> void:
_placeholder_notice.text = "LIVE DATA ACTIVE"
# =============================================================================
# Input — LEFT/RIGHT arrow navigation between systems
# =============================================================================
func _gui_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.is_echo():
match event.keycode:
KEY_LEFT:
_cycle_system(-1)
get_viewport().set_input_as_handled()
KEY_RIGHT:
_cycle_system(1)
get_viewport().set_input_as_handled()
func _cycle_system(direction: int) -> void:
## Cycle the system selector by delta steps (+1 or -1).
## Called from main.gd _unhandled_key_input — [ and ] keys when panel is active.
func navigate(delta: int) -> void:
if _systems.is_empty():
return
_selected_idx = wrapi(_selected_idx + direction, 0, _systems.size())
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
selected_system = _systems[_selected_idx].get("system_id", "")
_rebuild_panel()
-1
View File
@@ -135,7 +135,6 @@ func _process(_delta: float) -> void:
_dirty = false
## Called from main.gd when insert state changes.
## Called from main.gd when insert state changes.
func set_insert_active(active: bool) -> void:
_insert_active = active
+2 -2
View File
@@ -10,11 +10,11 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
| File | Domain | Decisions |
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152 |
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-191 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107, D-121, D-122, D-123, D-124, D-125, D-126, D-127, D-128, D-129, D-130, D-131, D-132, D-138, D-139, D-140, D-142, D-147 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091, D-114, D-115, D-116, D-117, D-118, D-119, D-120, D-145, D-146, D-153, D-154, D-155, D-156, D-157 |
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187 |
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187, D-189, D-190 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [questions.md](questions.md) | Open questions (index) | Q-001 through Q-094 |
| [questions-architecture.md](questions-architecture.md) | Technical questions | Q-001, Q-006, Q-009, Q-018–Q-023, Q-029, Q-030, Q-046, Q-059, Q-060, Q-063–Q-094 |
+84 -1
View File
@@ -654,4 +654,87 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*51 decisions. Last updated: 2026-04-06 (D-188 biome_summary → planet_class rename)*
### D-191: Atlas of the Reach — Phase 3 Scope and Pipeline
- **Date:** 2026-04-10
- **Decision:** Phase 3 delivers the Atlas of the Reach as an extension of the implant map (`implant/map`), adding planetary and regional zoom levels to the existing star map. The Atlas is a read-only spatial intelligence tool — the player looks at it to plan, not to execute actions. It serves dual purpose: gameplay information layer and world-texture content system.
**1. Zoom Hierarchy**
- 4 levels: Reach map (Phase 1, exists) → System view (orbital diagram, new) → Planetary view (hemisphere, new) → Regional view (hundreds-of-km, new — core Phase 3 deliverable)
- Atlas is the star map extended downward, not a separate app. `implant/map` at different zoom levels.
- Station maps and underground/cave city maps are DEFERRED to a later sprint (similar bounded generation pattern).
**2. Existing Foundation**
- 2,394 heightmap PNGs (1024×512 equirectangular, production quality)
- 2,394 markers.json files with procedural geometry (rivers, oceans, mountains) — all names null, all cities/roads/rail/POIs empty
- Planet-gen pipeline (`tooling/planet-gen/`) explicitly designed for Phase 3: `render_heightmap.py` line 26-27 defers cultural overlay to the atlas app
- 301 systems in systems.db, 273 inhabited bodies, 466 stations, 668 gate links
**3. Content Pipeline — Sequential Settlement Growth Simulation**
- City placement is terrain-aware and sequential (not scatter)
- Capital first: favor river mouths (~50% of capitals), scored by habitability (temperature, moisture, slope), coastal access, flat hinterland
- Subsequent cities: grow along rail corridors from capital (multi-source Dijkstra on cost grid)
- At cities 3–4: first foothold on a new continent if one exists (port city)
- Roads and rail: A* pathfinding on terrain cost grid (water=impassable, mountains=expensive, rivers=cheap corridors), minimum spanning tree — NO straight lines
- Quadrant distribution: after 2 cities in the same map quadrant, subsequent cities must prefer unoccupied quadrants unless those quadrants have no habitable land
- Variation: ±25% noise on scoring per seed — same terrain, different seed → different city network
- Settlement pattern modifies spacing (`urban_concentrated`=tight, `dispersed`=wide)
- All systems same depth, scaled by population. No manual tier classification.
**4. Naming Pipeline — Gemma 2 Voice Pipeline**
- Geographic names generated by the existing Gemma 2 voice pipeline (`server/src/voice/`, `sr-voice` binary)
- Input: body wiki page (`planet_class`, `cultural_corridor`, `settlement_pattern`, population, economic_role, atmospheric_tone) + corridor naming palette
- Output: culturally-appropriate names for rivers, oceans, mountains, regions, cities
- Corridor palettes: north_reach (Anglo-Saxon), south_reach (Iberian/Portuguese), east_reach (East Asian), west_reach (Germanic/Nordic), inner_orbit (institutional Latin/Anglo)
- Dual purpose: Phase 3 content generation AND quality/consistency test of the in-game LLM pipeline
- Batch throughput: ~80 min for all 2,394 bodies (placement) + ~40 min for 273 inhabited (naming). Parallelizable.
- Earth-name blocklist as post-processing safety net. Dedup check against full name corpus.
**5. Core Systems as Templates**
- Core systems (Gateway/Sirius, Groombridge/Lendel, etc.) hand-authored as templates
- Templates establish rulesets and quality bar for the generator
- Generator produces all remaining bodies → hand-author refinements over the batch
- Lendel (GJ 380c) is the first proving ground: arid, `urban_concentrated`, financial hub, 900M pop
**6. Atlas Panel Architecture**
- FULLSCREEN implant app (z=20), same component library as economics panel
- 3 navigation levels: system picker → orbital diagram → body atlas (heightmap + overlays)
- Heightmap displayed as `Texture2D` with pan/zoom
- Marker overlay renders cities, roads, rail, POIs, named features on top of heightmap
- Click city → City Data Panel (name, population, currency zone, Commission presence, shadow economy zone, gate distance, economics panel link)
- Economics panel link: click-through to Phase 2 economics panel pre-filtered to that node — primary Phase 2/3 integration point
**7. Overlay System — 9 MVP Overlays**
- 5 always-on: terrain, infrastructure, named features, gate/spaceport POIs, political zones
- 4 toggleable: population density, production zones, shadow economy zones (broad bands), corporate presence (Tier 1 only)
- Deferred overlays visible in toggle bar but locked with unlock requirements shown on hover (creates pull toward Phase 4+ systems)
- Maps to D-181 signal visibility ladder
**8. Settlement Data Model**
- markers.json schema per body: `cities` (name, lat/lon, population_tier, primary_function, gate_terminal, continent_id), `roads` (path polylines, connects), `railroads` (path polylines, connects), `pois` (name, kind, position), plus existing rivers/oceans/mountains with names filled
- Population tier → city count: `floor(log10(pop/1M))`, modified by `settlement_pattern`
- Gate terminal POI: at largest population center, sometimes scattered to a smaller one
- Moons: same depth as planets, scale with population
**9. Implementation Pipeline**
- Python: `tooling/planet-gen/generate_atlas.py` — reuses `planet_simulation.simulate()`, adds `city_placement.py`, `infrastructure_gen.py`, `gemma_naming.py`, `markers_writer.py`
- `make atlas-generate` runs the full batch. Deterministic per seed. Incremental (skips up-to-date bodies).
- Pipeline order: simulate terrain → analyze (continents, habitability, river mouths, cost grid) → place cities sequentially → generate infrastructure (A* MST) → name via Gemma 2 → write markers.json
**10. MVP Completion Criteria**
1. Navigation chain works end-to-end (Reach → system → planet → regional)
2. Regional map content complete for all inhabited bodies
3. Population-scaled depth (core systems rich, frontier sparse)
4. City data panel works (click any city, see data + economics link)
5. Economics panel integration works (link opens Phase 2 panel filtered to node)
6. All 9 MVP overlays present and functional
7. Atlas is read-only (no verbs execute from map)
8. Stations show at system view with mini data panel (no drill-down)
- **Rationale:** The heightmap pipeline was designed with Phase 3 in mind — 2,394 base maps exist. The critical path is content (filling empty markers.json arrays), not technology (the atlas panel follows the established implant component pattern). Sequential settlement growth simulation produces more realistic city networks than scatter placement — each city's location is informed by previous placements, terrain, and economic logic. Using the Gemma 2 voice pipeline for naming tests the in-game LLM quality while generating content. Population-scaled depth without manual tier classification keeps the pipeline simple and the authoring burden manageable.
- **Raised by:** Full planning team workshop, Sprint 34 (#748). Participants: Gestalt (systems design), Tyre (technical architecture), Miri (worldbuilding), with Jeroen as workshop participant.
- **Dissent:** None.
- **Cross-reference:** [D-166](architecture.md#d-166) (development cascade — Phase 3), [D-036](content.md#d-036) (Sova as canonical setting), [D-093](content.md#d-093-sova-transit-district--spatial-layout-and-district-topology) (Sova spatial layout), [D-094](#d-094) (district hierarchy), [D-095](content.md#d-095) (Horizon stations), [D-170](#d-170) (HUD visibility/implant apps), [D-169](#d-169) (implant component library), [D-181](economics.md#d-181-signal-vocabulary) (signal vocabulary/visibility ladder), [D-174](economics.md#d-174-shadow-economy-layer) (shadow economy intensity), [D-175](economics.md#d-175-corporation-taxonomy-and-prerequisite) (corporation taxonomy), [D-138](content.md#d-138-llm-re-voicing-pipeline-for-npc-voice) (Gemma 2 voice pipeline)
---
*53 decisions. Last updated: 2026-04-10 (D-191 Atlas of the Reach — Phase 3 scope and pipeline)*
+137 -1
View File
@@ -304,4 +304,140 @@ This domain covers: currency system, commodity taxonomy, shadow economy, corpora
---
*17 decisions (D-171–D-187), 1 rejected alternative (R-011). Domain: economics. Last updated: 2026-04-05.*
### D-189: Brand Layer Architecture
- **Date:** 2026-04-10
- **Decision:** The simulation supports a brand layer above the commodity tâtonnement. Brands are NOT commodities (D-185). They consume commodities as demand nodes and are priced through an administered pricing model with cultural premium curves. The brand layer serves dual purposes: economic simulation (demand nodes, pricing, GDP contribution) and queryable localized content for client UI (bar shelves, restaurant menus, shop displays, entertainment listings).
**1. Brand Taxonomy**
- 8 categories: `terroir`, `heritage_craft`, `tech_premium`, `cultural`, `service_premium`, `commodity_branded`, `design_heritage`, `platform_catalogue`
- `value_trajectory`: `appreciating` | `depreciating` | `timeless` (first-class field)
- 3 scale tiers for generated brands: local (1–3 systems), regional (corridor-scale), reach-wide budget (everywhere, corridor-neutral naming, no cultural premium)
**2. Pricing Model**
```
brand_price = max(price_floor, [base_cost × (1 + target_margin) + cultural_premium × (1 + veblen × scarcity)] × value_trajectory_factor × currency_factor)
```
- Cultural premium split into `identity_term` + `exotic_term` with `exotic_floor` to support 3 curve types: Scarcity-Distance (artisan), Dual-Peak (media/content), Aspirational Gradient (tech)
- Two-component scarcity: structural (`production_volume / addressable_demand` ratio, permanent) + situational (stockpile depletion, temporary)
- Veblen per-location derived: `veblen_base × income_quintile × corridor_affinity` — zero authoring cost
- `cost_passthrough_ratio`: insulates brand pricing from tâtonnement volatility (low 0.10–0.25 for terroir, high 0.50–0.75 for tech)
- `value_trajectory_factor`: appreciating goods gain value with vintage age, depreciating goods lose value with a floor, timeless = 1.0; updated per game-year
**3. Halo/Volume Tier Structure**
- Universal pattern: every notable brand has a halo product (defines identity ceiling) + volume tier(s) (makes the brand economically relevant at population scale ~80B)
- `halo_lift_factor`: volume tier borrows a fraction of the halo's cultural premium
- Direction inverts by category: terroir pushes scarcity up, tech/media pushes quality up from a mass base
- Brands without volume tiers are economically marginal regardless of prestige — population asymmetry (10B systems vs. 50k) makes this structurally necessary
**4. Brand Census**
- Notable (hand-authored): 120–170 corps with TOML records; ~27 currently named
- Minor (template-generated): ~10,000 brands from ~120–130 template definitions (35–40 archetypes × 3 sub-variants), corridor-specific naming patterns, 3 scale tiers
- Naming patterns: corridor-appropriate — north_reach = British/Australian inflection, east_reach = Korean/Japanese, west_reach = German/Dutch/Nordic, south_reach = Portuguese/Swahili, inner_corridor = pan-corridor neutral, frontier = founder surname + noun
- Queryable content: `brand_products JOIN corp_presence` filtered by location and `product_subcategory`; sub-millisecond at 10K+ rows — primary use case alongside economic simulation
**5. DB Schema**
- `brand_products`: `brand_product_id`, `corp_id`, `product_name`, `brand_category` (8 values), `value_trajectory`, `scarcity_class` (`capped` / `constrained` / `scalable` / `unlimited`), `product_subcategory`, `base_premium_multiplier`, `premium_floor`, `origin_system`, `terroir_locked`, `currency_denomination`, `shadow_viable`, `brand_tier` (`halo` / `volume`), `halo_brand_id` (for volume tiers)
- `brand_inputs`: `brand_product_id`, `commodity_id`, `quantity`
- `system_fiscal`: `system_id`, `corp_tax_rate`, `collection_efficiency` (derived from `shadow_economy_intensity`)
- `corp_financial_state` + `corp_lifecycle_events` tables for acquisition/startup lifecycle
- Composite index on `brand_products(corp_id, brand_category)` for UI queries
**6. Corp Tax & GDP**
- Corp tax = `revenue × (1 - category_deduction) × tax_rate × collection_efficiency`
- Category deductions: terroir 0.30, geological 0.20, tech 0.60, media 0.15, vehicles 0.55, apparel 0.45
- Tax flows to HQ system; GDP computed every 100 ticks
- `collection_efficiency = 1.0 - shadow_economy_intensity × 0.6`
- Visible line item when player owns a company
**7. Player Verb Ladder**
- 6 stages: Operate → Specialize → Distribute → Create → Scale → Corporate
- Founding verbs: `brand`, `register`, `market`
- Content verbs: `acquire-rights`, `royalty-contract`, `exclusive-window`
- Corporate verbs: `acquire`, `merge`, `spin-off`, `license-out`
- Temporal verbs: `cellar` (appreciating), `refresh` (depreciating), `license-legacy` (EOL)
- `authenticate` is the mechanically richest new verb — 3 service economies: expert appraisal (artisan), Commission inspection (tech/vehicles), Meridian rights lookup (content)
**8. Acquisition & Startup Lifecycle**
- Corp lifecycle states: Founded → Growing → Active → Distressed → Acquired/Dissolved
- Startup triggers: market gap, spin-off, player-founded, storyteller event
- Acquisition triggers: financial distress, strategic AI acquisition, player-initiated, hostile takeover event
- Player acquisition and mergers are in scope; if acquisition exists, startups must exist (or the well dries)
- Phase 2: corp health tracking as passive metric. Phase 3: lifecycle state machine, startup generation, player acquisition
**9. Events**
- `BrandPrestigeShock` via EconEvent port (D-180) — very occasional scandal events (pollution, fraud)
- Exponential decay with authored half-life
- Direct-vector only: events hit the brand's known vectors (commodity input price, production location); no indirect cascading beyond that
**10. Phase 2 Boundary**
- Phase 2 (this sprint cycle): brand corps as commodity demand stubs in tâtonnement, `brand_products` / `brand_inputs` / `system_fiscal` schema, `corp_financial_state` passive tracking, `generate_brands` pipeline
- Brand layer (post-Phase 2): pricing engine, awareness propagation, cultural preference curves, aging pipeline simulation, lifecycle state machine, player acquisition verbs
**11. Named Brand Corps (~27)**
| Corp | Category | Origin |
|------|----------|--------|
| Calloway Distillery | terroir | north_reach |
| VGV | terroir | west_reach |
| thrds | heritage_craft | north_reach |
| Bífröst Marmor | terroir | north_reach / Compact |
| Destilaria Confluência / Lento | terroir | south_reach |
| Veldfontein Botanical | terroir + heritage_craft | south_reach |
| Comptoir Lendel | terroir + service_premium | inner_core |
| Maison Cinq | design_heritage | inner_core / Gateway |
| MVG (Manifattura Veicoli Gherardi) | design_heritage | west_reach Italian |
| Higashiyama Vehicle Engineering | tech_premium + design_heritage | east_reach |
| Rijdbaar Personal Mobility | design_heritage | west_reach / Compact |
| Byeolbit Entertainment | cultural + platform_catalogue | east_reach |
| Leerfeld Records | cultural | west_reach / Compact |
| Vuma Sound | cultural | south_reach |
| Resonance Premium | platform_catalogue | inner_core |
| Dalbit Systems | tech_premium + platform_catalogue | east_reach |
| Arclamp | cultural + design_heritage | inner_core |
| Kellervolk | cultural | Compact |
| Hangang Studio | platform_catalogue | east_reach |
| Hanyang Precision | — (Tier 1, branded_products) | — |
| Sato Medical | — (Tier 1, branded_products) | — |
| Takamori Lattice | — (Tier 1, branded_products) | — |
| Thalassa Resort | — (Tier 1, branded_products) | — |
| Somatic Futures | — (Tier 1, branded_products) | — |
| The Registry | — (Tier 1, branded_products) | — |
| Meridian Risk | — (Tier 1, branded_products) | — |
- **Rationale:** Administered pricing is the correct model because brands violate all three tâtonnement assumptions: heterogeneity (Calloway ≠ VGV ≠ generic spirits), supply inelasticity (terroir production cannot respond to price signals per D-177), and Veblen demand effects (prestige goods can have upward-sloping demand). The one-way interface (commodity prices → brand input costs; brand output prices do NOT feed back into tâtonnement) is architecturally clean and matches the D-178 layer model. The identity/exotic split in cultural premium is the minimal structural addition needed to produce all three observed pricing curves (Scarcity-Distance, Dual-Peak, Aspirational Gradient). Population asymmetry (~80B total Reach population, systems ranging from 10B to <50k) makes the halo/volume tier pattern structurally necessary — brands from tiny worlds are astronomically exclusive and need volume derivatives to be economically relevant.
- **Raised by:** Full planning team workshop, Sprint 34 (#811).
- **Dissent:** None.
- **Cross-reference:** [D-185](#d-185-brands-are-not-commodities) (brands are not commodities), [D-184](#d-184-commodity-catalog-36-types) (commodity catalog), [D-177](#d-177-productivity-constraints-lore-derived) (productivity constraints), [D-175](#d-175-corporation-taxonomy-and-prerequisite) (corporation taxonomy), [D-178](#d-178-economic-model-architecture) (economic model architecture), [D-180](#d-180-event-input-port) (event input port), [D-181](#d-181-signal-vocabulary) (signal vocabulary), [D-173](#d-173-commodity-taxonomy) (commodity taxonomy), [D-171](#d-171-three-currency-system) (three-currency system), [D-131](content.md#d-131-broad-economic-verb-vocabulary--life-verbs-not-tycoon-specific) (economic verb vocabulary), [D-118](scope.md#d-118-small-business-owner-starting-state--tycoon-is-aspiration-not-starting-position) (small business owner starting state)
---
### D-190: Brand Volume Calibration — Population-Relative Scale
- **Date:** 2026-04-10
- **Decision:** All brand production and distribution volume numbers must be specified relative to a reference population, not as absolute counts. Volume without a reference population is not meaningful at Reach scale:
- **Scale reference table (~80B Reach):**
- Single-system local phenomenon: 100M–500M (1–5% of a 10B system)
- Corridor-known hit: 250M–1B (~0.5% of corridor addressable population)
- Reach-wide genuine hit: ~1B (1 in 80 Reach population)
- All-time Reach canonical: 2B–5B (1 in 16–40 Reach population)
- **Earth rule of thumb:** multiply Earth-scale phenomenon volumes by 12–15× for comparable cultural penetration. A 100M-seller on Earth ≈ 1.2–1.5B in the Reach.
- **40M benchmark:** 40M units/streams Reach-wide = 0.04% penetration — a cult hit or successful regional release, not a cultural touchstone. 40M within a single large system (10B population) = 0.4% — respectable but not legendary.
- **Authoring rule:** wiki volume figures for media brands and any good with `brand_category = cultural` must include a `reference_population` annotation alongside the count. "40M" is incomplete; "40M (west_reach corridor, ~10B addressable)" is correct. This applies to corporation production ceilings, media distribution figures, and market penetration estimates in wiki pages and TOML files.
- **Structural scarcity principle:** for physical brand goods, production volume only has meaning relative to addressable demand. The `structural_scarcity_base` parameter in the brand pricing layer (D-189) is derived from this ratio: `1.0 - min(1.0, annual_volume / (addressable_population × demand_rate))`. A 12,000-unit/year artisan product against 100M addressable consumers yields structural_scarcity_base ≈ 0.88 — perpetually near-maximum scarcity regardless of local stockpile state. This scarcity floor is permanent, not situational.
- **Rationale:** The Reach's population asymmetry (core systems 10B+, frontier systems under 50K) makes absolute volume numbers meaningless without a reference population. Without an explicit calibration rule, brand and media content significance will be systematically miscalibrated across all authoring. The structural scarcity principle connects volume calibration to the administered pricing model: a brand's Veblen premium floor is derived from the same population-relative ratio, ensuring that pricing and authoring are grounded in the same underlying reality.
- **Raised by:** Jeroen (population asymmetry insight), Burnelli-Sheldon (structural scarcity derivation and calibration table), Sprint 34 Workshop #811.
- **Dissent:** None.
- **Cross-reference:** [D-189](#d-189-brand-layer-architecture) (structural_scarcity_base parameter), [D-177](#d-177-productivity-constraints-lore-derived) (lore-constrained production ceilings), [D-175](#d-175-corporation-taxonomy-and-prerequisite) (corporation production volumes)
---
## Rejected Alternatives
### R-011: Single currency for Phase 2 (rejected)
- **Date:** 2026-04-05
- **Proposed by:** Burnelli-Sheldon (economist), Sprint 32 Workshop #796 Round 1
- **Proposal:** Use a single currency for the Phase 2 economics simulation to reduce model complexity. Exchange rate mechanics could be added in a later phase.
- **Rejected because:** Three currencies create structural economic bloc tension as an emergent property of initialization — no event generation required. The Tractus/Mark divide maps directly to the Assembly vs. Compact political divide that is already canonical lore. Deferring currencies to a later phase would require retrofitting political geography into an already-running simulation. The complexity cost of three currencies is low; the design value is high.
- **Raised by:** Lead directive overruling the recommendation.
---
*19 decisions (D-171–D-187, D-189–D-190), 1 rejected alternative (R-011). Domain: economics. Last updated: 2026-04-10.*
+1 -1
View File
@@ -1,5 +1,5 @@
name: The Settled Reach
version: 0.1.33
version: 0.1.34
repository: settled-reach
+13 -1
View File
@@ -570,6 +570,17 @@ version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "econ-sim"
version = "0.1.0"
dependencies = [
"clap",
"rand",
"rand_chacha",
"rusqlite",
"serde",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1245,13 +1256,14 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"bevy_app",
"bevy_ecs",
"bincode",
"clap",
"crossbeam-channel",
"econ-sim",
"pathfinding",
"rand",
"rand_chacha",
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "settled-reach-server"
version = "0.1.33"
version = "0.1.34"
edition = "2021"
[dependencies]
@@ -23,6 +23,8 @@ sysinfo = "0.35"
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
toml = "0.8"
# Economics simulation — Leontief + tâtonnement + D-180 event port (#821)
econ-sim = { path = "../tooling/econ-sim" }
[features]
default = ["gauntlet"]
File diff suppressed because it is too large Load Diff
+136 -1
View File
@@ -8,10 +8,14 @@
use bevy_ecs::prelude::*;
use crate::bridge::types::{DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer};
use crate::bridge::types::{
DebugCommandKind, DebugEnabled, DebugResponsePayload, EconDebugEffect, EconParamKind,
SnapshotBuffer,
};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::conversation::NpcName;
use crate::simulation::economy::{EconSimResource, EconStateResource};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
@@ -69,6 +73,8 @@ pub fn handle_debug_commands(
(Entity, &TilePosition, Option<&NpcName>),
(With<Npc>, With<ActiveSim>, Without<PlayerCharacter>),
>,
mut econ_sim: Option<ResMut<EconSimResource>>,
econ_state: Option<Res<EconStateResource>>,
) {
// Gate: debug must be enabled
let enabled = debug_enabled.as_ref().is_some_and(|d| d.0);
@@ -325,6 +331,135 @@ pub fn handle_debug_commands(
}
}
}
DebugCommandKind::InjectEconEvent {
ref target,
ref effect,
magnitude,
duration_ticks,
} => {
use econ_sim::events::{
EconEvent, EconEventEffect, EconEventTarget, EconEventVisibility,
};
if let Some(ref mut sim) = econ_sim {
let econ_effect = match effect {
EconDebugEffect::CapacityMultiplier => {
EconEventEffect::CapacityMultiplier(magnitude)
}
EconDebugEffect::ProductivityMultiplier => {
EconEventEffect::ProductivityMultiplier(magnitude)
}
EconDebugEffect::DemandShock => EconEventEffect::DemandShock(magnitude),
EconDebugEffect::ExchangeShock => {
EconEventEffect::ExchangeShock(magnitude)
}
};
sim.sim.events.push(EconEvent {
target: EconEventTarget::Node(target.clone()),
effect: econ_effect,
duration: duration_ticks,
visibility: EconEventVisibility::Global,
});
DebugResponsePayload {
command: format!("InjectEconEvent({}, {:?}, {}×{})", target, effect, magnitude, duration_ticks),
text: format!(
"Event injected: {:?} ×{} on node '{}' for {} ticks.\nTakes effect on next economy tick.",
effect, magnitude, target, duration_ticks
),
success: true,
}
} else {
DebugResponsePayload {
command: "InjectEconEvent".to_string(),
text: "Economy simulation not loaded.".to_string(),
success: false,
}
}
}
DebugCommandKind::SetEconParam { ref param, value } => {
if let Some(ref mut sim) = econ_sim {
match param {
EconParamKind::TatonnementStep => {
let old = sim.sim.alpha;
sim.sim.alpha = value;
DebugResponsePayload {
command: format!("SetEconParam(TatonnementStep, {})", value),
text: format!("α (tâtonnement step): {} → {}", old, value),
success: true,
}
}
EconParamKind::DampingFactor => {
let old = sim.sim.beta;
sim.sim.beta = value;
DebugResponsePayload {
command: format!("SetEconParam(DampingFactor, {})", value),
text: format!("β (damping factor): {} → {}", old, value),
success: true,
}
}
EconParamKind::CorridorFriction { ref corridor_id } => {
DebugResponsePayload {
command: format!("SetEconParam(CorridorFriction({}))", corridor_id),
text: "Per-corridor friction override not yet implemented (requires corridor friction model in trade.rs).".to_string(),
success: false,
}
}
}
} else {
DebugResponsePayload {
command: "SetEconParam".to_string(),
text: "Economy simulation not loaded.".to_string(),
success: false,
}
}
}
DebugCommandKind::GetEconState { ref system_id } => {
if let Some(ref state) = econ_state {
let signals: Vec<_> = state
.signals
.iter()
.filter(|((sys, _), _)| sys == system_id)
.collect();
if signals.is_empty() {
DebugResponsePayload {
command: format!("GetEconState({})", system_id),
text: format!("System '{}' not found in economy state.", system_id),
success: false,
}
} else {
let mut lines = vec![
format!(
"=== Economy state for '{}' (econ_tick={}) ===",
system_id, state.econ_tick
),
format!(" FX rate (Tractus/Mark): {:.4}", state.tractus_mark_rate),
];
for ((_, commodity_id), sig) in &signals {
lines.push(format!(
" {} | price={:.2} trend={:+.2} flow={:.1} corps={} stockpile_wks={:.1} prod_vs_base={:.3} coverage={:.2}",
commodity_id,
sig.price_current,
sig.price_trend,
sig.trade_flow_volume,
sig.corporate_presence,
sig.stockpile_weeks,
sig.production_vs_baseline,
sig.official_coverage_ratio,
));
}
DebugResponsePayload {
command: format!("GetEconState({})", system_id),
text: lines.join("\n"),
success: true,
}
}
} else {
DebugResponsePayload {
command: format!("GetEconState({})", system_id),
text: "Economy simulation not loaded.".to_string(),
success: false,
}
}
}
}
};
+1
View File
@@ -227,6 +227,7 @@ impl Plugin for BridgePlugin {
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
debug::handle_debug_commands
.after(crate::simulation::input::process_player_input)
.after(crate::simulation::economy::tick_economy_simulation)
.before(crate::perception::observer::compute_observer_snapshot),
crate::perception::observer::compute_visibility_geometry
.after(crate::simulation::movement::validate_movement),
+2
View File
@@ -320,6 +320,7 @@ mod tests {
debug_response: None,
current_ticker: None,
settings_response: None,
economy_snapshot: None,
}
}
@@ -461,6 +462,7 @@ mod tests {
debug_response: None,
current_ticker: None,
settings_response: None,
economy_snapshot: None,
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+98 -1
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 20;
pub const PROTOCOL_VERSION: u8 = 21;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -81,6 +81,8 @@ pub struct StartupMessage {
/// v18 adds: debug_response (#580, debug console server — command/response wire).
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
/// v20 adds: settings_response (#627, SQLite settings IPC).
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
/// EconStateQuery PlayerAction variant (#822).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
@@ -216,6 +218,12 @@ pub struct ObserverSnapshot {
/// Client reads to confirm setting changes or to populate the settings UI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_response: Option<crate::settings::types::SettingsResponseWire>,
/// Economy snapshot (#822, D-181 7-signal snapshot).
/// Present for exactly one tick after an `EconStateQuery` is processed.
/// Contains all 7 D-181 signals for each commodity in the queried system.
/// None during normal gameplay; client queries explicitly via `EconStateQuery`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub economy_snapshot: Option<EconomySnapshot>,
}
/// A single news ticker headline crossing the wire boundary (#591).
@@ -550,6 +558,12 @@ pub enum PlayerAction {
DeleteSetting {
key: String,
},
/// Query economy state for a named system (#822, D-181).
/// Server responds with `ObserverSnapshot.economy_snapshot` for one tick.
/// Absent when economy is not loaded or `system_id` is unknown.
EconStateQuery {
system_id: String,
},
}
impl PlayerAction {
@@ -595,6 +609,21 @@ pub enum DebugCommandKind {
ListPopulation,
/// Return `ContaminationActive` status and current tick.
GetContaminationStatus,
/// Inject a D-180 economic event into the running simulation (#823).
/// The event fires at the next economy tick and lasts for `duration_ticks`.
/// `target` is a system_id (node-level events only in v0.1).
InjectEconEvent {
target: String,
effect: EconDebugEffect,
magnitude: f64,
duration_ticks: u32,
},
/// Mutate a simulation parameter at runtime (#823, D-178).
/// Changes take effect on the next `Simulation::step()` call.
SetEconParam { param: EconParamKind, value: f64 },
/// Return all 7 D-181 signals for the named system (#823).
/// Equivalent to `EconStateQuery` but via the debug console.
GetEconState { system_id: String },
}
/// Debug response payload included in `ObserverSnapshot` (#580).
@@ -612,6 +641,72 @@ pub struct DebugResponsePayload {
pub success: bool,
}
/// Wire type for a single commodity's 7 D-181 signals at a node (#822).
///
/// Compact snapshot used in `EconomySnapshot.nodes`. Mirrors `EconNodeSignals`
/// in `simulation::economy` but is Serializable for wire transmission.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EconNodeSnapshot {
pub commodity_id: String,
/// Signal 1: current market price in Tractus (Public).
pub price_current: f64,
/// Signal 2: price delta over last TREND_WINDOW economy ticks (Public).
pub price_trend: f64,
/// Signal 3: trade flow volume proxy (Observable).
pub trade_flow_volume: f64,
/// Signal 4: number of corporations at this node (Observable).
pub corporate_presence: u32,
/// Signal 5: stockpile in weeks at current demand rate (Semi-private).
pub stockpile_weeks: f64,
/// Signal 6: supply vs. baseline supply from first tick (Private).
pub production_vs_baseline: f64,
/// Signal 7: ratio of formal to total activity (Meta-signal).
pub official_coverage_ratio: f64,
}
/// Wire type for economy state snapshot (#822, D-181).
///
/// Returned in `ObserverSnapshot.economy_snapshot` for one tick after an
/// `EconStateQuery` is processed. Contains signals for all commodities in the
/// queried system. None when economy is not loaded or system_id is unknown.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EconomySnapshot {
/// The system this snapshot covers.
pub system_id: String,
/// Economy tick at which this snapshot was produced.
pub econ_tick: u64,
/// Current Tractus/Mark exchange rate (1.0 = parity).
pub tractus_mark_rate: f64,
/// Signals for each commodity active in this system.
pub nodes: Vec<EconNodeSnapshot>,
}
/// Effect type for `InjectEconEvent` debug command (#823, D-180).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EconDebugEffect {
/// Multiply production capacity of the target node by `magnitude`.
/// < 1.0 = capacity shock; > 1.0 = capacity boost.
CapacityMultiplier,
/// Multiply productivity of all operations at the target node by `magnitude`.
ProductivityMultiplier,
/// Add `magnitude` to demand for all commodities at the target node.
DemandShock,
/// Apply a one-time exchange rate shock of `magnitude` to the FX rate.
ExchangeShock,
}
/// Parameter selector for `SetEconParam` debug command (#823, D-178).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EconParamKind {
/// Tâtonnement step size (α, D-178 Layer 2). Default: 0.03.
TatonnementStep,
/// Trade flow damping factor (β, D-178). Default: 0.4.
DampingFactor,
/// Per-corridor friction override (not yet implemented in simulation).
#[allow(dead_code)] // Used by future corridor friction model (#TODO)
CorridorFriction { corridor_id: String },
}
/// Whether the debug console is enabled (#580).
///
/// Set at server startup. Cannot be toggled mid-session via IPC.
@@ -941,6 +1036,8 @@ pub struct SnapshotBuffer {
pub pending_debug_response: Option<DebugResponsePayload>,
/// Pending settings response, consumed once by `compute_observer_snapshot` (#627).
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
/// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822).
pub pending_economy_response: Option<EconomySnapshot>,
}
#[cfg(test)]
+1
View File
@@ -328,6 +328,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
debug_response: None,
current_ticker: None,
settings_response: None,
economy_snapshot: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
+4
View File
@@ -394,6 +394,9 @@ pub fn compute_observer_snapshot(
None
};
// Consume pending economy snapshot for this tick (#822).
let economy_snapshot = buffer.pending_economy_response.take();
// Consume pending save/load result for this tick (#553).
let save_result = buffer.pending_save_result.take();
@@ -489,6 +492,7 @@ pub fn compute_observer_snapshot(
debug_response,
current_ticker,
settings_response,
economy_snapshot,
});
}
+375
View File
@@ -0,0 +1,375 @@
//! Economy simulation integration — runs econ-sim inside the server tick loop.
//!
//! Bridges the standalone `econ_sim` library into the Bevy ECS tick loop.
//! The simulation advances one economy tick every `ECON_TICK_RATE` game ticks.
//!
//! ## Architecture
//!
//! - [`EconSimResource`] — holds the running `Simulation` + price history for trends.
//! Loaded once at startup from `server/data/systems.db`. Never reloaded mid-session.
//!
//! - [`EconStateResource`] — all 7 D-181 signals per active (system_id, commodity_id).
//! Updated every `ECON_TICK_RATE` game ticks by `tick_economy_simulation`.
//! Queryable by the IPC bridge (#822) and debug commands (#823).
//!
//! - `tick_economy_simulation` — bevy System registered in `SimulationPlugin`.
//! Advances one economy tick, then rebuilds `EconStateResource`.
//!
//! ## Rate (D-031)
//!
//! `ECON_TICK_RATE = 10` game ticks per economy tick.
//! At 10 game ticks/game-minute (D-031), this means the economy advances once
//! per game-minute — a reasonable granularity for macro-scale price dynamics.
//!
//! ## D-181 signals
//!
//! 1. `price_current` — current market price (Public)
//! 2. `price_trend` — Δprice over the last `TREND_WINDOW` economy ticks (Public)
//! 3. `trade_flow_volume` — supply volume proxy (Observable; Phase 3 will refine)
//! 4. `corporate_presence` — corp count at this node (Observable)
//! 5. `stockpile_weeks` — stockpile ÷ weekly demand rate (Semi-private)
//! 6. `production_vs_baseline` — supply ÷ initial baseline supply (Private)
//! 7. `official_coverage_ratio` — 1 − shadow_intensity (Meta-signal)
use std::collections::{BTreeMap, VecDeque};
use bevy_ecs::prelude::*;
use econ_sim::Simulation;
use crate::bridge::types::{EconNodeSnapshot, EconomySnapshot, SnapshotBuffer};
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Game ticks between each economy tick (D-031: 10 ticks/game-minute).
pub const ECON_TICK_RATE: u64 = 10;
/// Number of economy ticks to average for price trend signal 2 (D-181).
const TREND_WINDOW: usize = 5;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// The running economics simulation (loaded once at startup).
///
/// Never reinitialize mid-session — the economy state is continuous.
#[derive(Resource)]
pub struct EconSimResource {
pub sim: Simulation,
/// Price history for signal 2 (price_trend) computation.
/// Ring-buffer keyed by (system_id, commodity_id) → last TREND_WINDOW prices.
price_history: BTreeMap<(String, String), VecDeque<f64>>,
/// Baseline supply from first economy tick for signal 6 (production_vs_baseline).
baseline_supply: BTreeMap<(String, String), f64>,
}
impl EconSimResource {
pub fn new(sim: Simulation) -> Self {
Self {
sim,
price_history: BTreeMap::new(),
baseline_supply: BTreeMap::new(),
}
}
}
/// The 7 D-181 signals for a single active (system_id, commodity_id) pair.
///
/// Updated every `ECON_TICK_RATE` game ticks. All fields present when the
/// node is active; queries for inactive nodes return nothing.
#[derive(Debug, Clone)]
pub struct EconNodeSignals {
pub system_id: String,
pub commodity_id: String,
/// Signal 1: current market price in Tractus (Public).
pub price_current: f64,
/// Signal 2: price delta over last `TREND_WINDOW` economy ticks (Public).
/// Positive = price rising; negative = falling. Absolute delta, not percentage.
pub price_trend: f64,
/// Signal 3: trade flow volume proxy — supply volume this tick (Observable).
/// Phase 2 proxy: actual inter-node flow tracking is Phase 3.
pub trade_flow_volume: f64,
/// Signal 4: number of corporations operating at this node (Observable).
pub corporate_presence: u32,
/// Signal 5: estimated stockpile in weeks at current demand rate (Semi-private).
pub stockpile_weeks: f64,
/// Signal 6: supply vs. baseline supply from first tick (Private).
/// 1.0 = at baseline; < 1.0 = below baseline; > 1.0 = above.
pub production_vs_baseline: f64,
/// Signal 7: ratio of formal to total (formal + shadow) activity (Meta-signal).
/// Derived from `shadow_intensity`: 1.0 = fully formal, 0.0 = fully shadow.
pub official_coverage_ratio: f64,
}
/// Current economy state — all 7 D-181 signals for all active nodes.
///
/// Updated every `ECON_TICK_RATE` game ticks. Queryable by the IPC bridge
/// (#822) and debug command handler (#823). Absent when the economy DB is
/// not loaded (graceful degradation).
#[derive(Resource, Default)]
pub struct EconStateResource {
/// Economy tick at which this snapshot was produced.
pub econ_tick: u64,
/// Current Tractus/Mark exchange rate (1.0 = parity).
pub tractus_mark_rate: f64,
/// Signal map: (system_id, commodity_id) → 7-signal snapshot.
pub signals: BTreeMap<(String, String), EconNodeSignals>,
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// System: advance the economy simulation one tick every `ECON_TICK_RATE` game ticks.
///
/// Runs after `time::advance_tick` (needs current game tick) and before
/// `compute_observer_snapshot` (so signals are fresh for the snapshot).
///
/// No-op when the game tick is not divisible by `ECON_TICK_RATE`.
/// Both `EconSimResource` and `EconStateResource` must be present (inserted
/// at startup only when the economy DB loaded successfully).
pub fn tick_economy_simulation(
time: Res<SimulationTime>,
econ_sim_opt: Option<ResMut<EconSimResource>>,
econ_state_opt: Option<ResMut<EconStateResource>>,
) {
let (mut econ_sim, mut econ_state) = match (econ_sim_opt, econ_state_opt) {
(Some(s), Some(st)) => (s, st),
_ => return, // economy not loaded — no-op
};
if !time.tick.is_multiple_of(ECON_TICK_RATE) {
return;
}
// Step the simulation one economy tick
econ_sim.sim.step();
let econ_tick = econ_sim.sim.tick();
let fx_rate = econ_sim.sim.tractus_mark_rate();
// Rebuild signal map from updated node states
rebuild_signals(&mut econ_sim, &mut econ_state, econ_tick, fx_rate);
}
// ---------------------------------------------------------------------------
// Signal computation
// ---------------------------------------------------------------------------
fn rebuild_signals(
econ_sim: &mut EconSimResource,
econ_state: &mut EconStateResource,
econ_tick: u64,
fx_rate: f64,
) {
econ_state.econ_tick = econ_tick;
econ_state.tractus_mark_rate = fx_rate;
econ_state.signals.clear();
// Collect shadow intensities and corp counts once (avoid repeated borrows)
let shadow_intensities: BTreeMap<String, f64> = econ_sim
.sim
.shadow()
.intensity
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
let corp_counts: BTreeMap<String, u32> = econ_sim
.sim
.economy()
.presences_by_system
.iter()
.map(|(sys, corps)| (sys.clone(), corps.len() as u32))
.collect();
// Snapshot current node states into the price_history and baseline_supply maps,
// then build signals. We need to separate the borrow from the iteration.
let node_snapshots: Vec<(String, Vec<(String, f64, f64, f64, f64)>)> = econ_sim
.sim
.nodes
.iter()
.map(|(system_id, node)| {
let commodities: Vec<(String, f64, f64, f64, f64)> = node
.commodities
.iter()
.map(|(commodity_id, state)| {
(
commodity_id.clone(),
state.price,
state.supply,
state.stockpile,
state.demand,
)
})
.collect();
(system_id.clone(), commodities)
})
.collect();
for (system_id, commodities) in &node_snapshots {
let shadow_intensity = shadow_intensities.get(system_id).copied().unwrap_or(0.0);
let corp_count = corp_counts.get(system_id).copied().unwrap_or(0);
for (commodity_id, price, supply, stockpile, demand) in commodities {
let key = (system_id.clone(), commodity_id.clone());
// Signal 6 baseline: record first-tick supply
econ_sim
.baseline_supply
.entry(key.clone())
.or_insert(*supply);
let baseline = *econ_sim.baseline_supply.get(&key).unwrap_or(supply);
// Signal 2: price trend via ring buffer
let history = econ_sim.price_history.entry(key.clone()).or_default();
history.push_back(*price);
if history.len() > TREND_WINDOW {
history.pop_front();
}
let price_trend = if history.len() >= 2 {
price - history[0]
} else {
0.0
};
// Signal 5: stockpile in weeks (7 economy ticks per week approximation)
let weekly_demand = demand * 7.0; // 7 econ ticks ≈ 1 week
let stockpile_weeks = if weekly_demand > 1e-9 {
*stockpile / weekly_demand
} else {
0.0
};
// Signal 6: production vs baseline
let production_vs_baseline = if baseline > 1e-9 {
supply / baseline
} else {
1.0
};
// Signal 7: official coverage ratio
let official_coverage_ratio = 1.0 - shadow_intensity;
econ_state.signals.insert(
key,
EconNodeSignals {
system_id: system_id.clone(),
commodity_id: commodity_id.clone(),
price_current: *price,
price_trend,
trade_flow_volume: *supply, // Phase 2 proxy
corporate_presence: corp_count,
stockpile_weeks,
production_vs_baseline,
official_coverage_ratio,
},
);
}
}
}
// ---------------------------------------------------------------------------
// IPC query buffer (#822)
// ---------------------------------------------------------------------------
/// Pending system_id from an `EconStateQuery` PlayerAction.
///
/// Populated by `process_player_input`; consumed by `serve_econ_state_query`.
/// `None` on ticks when no query was received.
#[derive(Resource, Default)]
pub struct EconQueryBuffer {
pub pending: Option<String>,
}
/// System: serve a pending `EconStateQuery` by building an `EconomySnapshot`
/// and storing it in `SnapshotBuffer.pending_economy_response`.
///
/// Runs after `tick_economy_simulation` (signals must be fresh) and before
/// `compute_observer_snapshot` (which consumes the response).
/// No-op when `EconStateResource` is absent or no query is pending.
///
/// **D-181 visibility (Phase 2):** All 7 signals are sent unfiltered.
/// Phase 3 will gate signals 3–7 behind the D-181 visibility ladder
/// (Observable → Semi-private → Private → Meta) based on the player's
/// information access at the queried node.
pub fn serve_econ_state_query(
mut query_buf: ResMut<EconQueryBuffer>,
econ_state: Option<Res<EconStateResource>>,
mut snapshot_buf: ResMut<SnapshotBuffer>,
) {
let system_id = match query_buf.pending.take() {
Some(s) => s,
None => return,
};
let econ_state = match econ_state {
Some(s) => s,
None => {
// Economy not loaded — no response (client receives None in snapshot)
tracing::debug!(system_id = %system_id, "EconStateQuery: economy not loaded");
return;
}
};
// Collect signals for all commodities in the requested system
let nodes: Vec<EconNodeSnapshot> = econ_state
.signals
.iter()
.filter(|((sys, _), _)| sys == &system_id)
.map(|((_, commodity_id), sig)| EconNodeSnapshot {
commodity_id: commodity_id.clone(),
price_current: sig.price_current,
price_trend: sig.price_trend,
trade_flow_volume: sig.trade_flow_volume,
corporate_presence: sig.corporate_presence,
stockpile_weeks: sig.stockpile_weeks,
production_vs_baseline: sig.production_vs_baseline,
official_coverage_ratio: sig.official_coverage_ratio,
})
.collect();
if nodes.is_empty() {
tracing::debug!(system_id = %system_id, "EconStateQuery: system not found in economy state");
return;
}
snapshot_buf.pending_economy_response = Some(EconomySnapshot {
system_id,
econ_tick: econ_state.econ_tick,
tractus_mark_rate: econ_state.tractus_mark_rate,
nodes,
});
}
// ---------------------------------------------------------------------------
// Startup helper
// ---------------------------------------------------------------------------
/// Attempt to load the economy simulation.
///
/// Returns `Some((EconSimResource, EconStateResource))` on success, `None` on
/// failure (with the error logged at warn level). The server inserts these as
/// resources when present; the economy features degrade gracefully when absent.
pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateResource)> {
match Simulation::load_auto(run_seed) {
Ok(sim) => {
tracing::info!(
commodities = sim.economy().commodities.len(),
active_nodes = sim.nodes.len(),
"Economy simulation loaded"
);
Some((EconSimResource::new(sim), EconStateResource::default()))
}
Err(e) => {
tracing::warn!(
error = %e,
"Economy simulation not loaded — economics features disabled for this session"
);
None
}
}
}
+10
View File
@@ -7,6 +7,7 @@ use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInpu
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
use crate::simulation::economy::EconQueryBuffer;
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
use crate::simulation::inventory::{
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
@@ -102,6 +103,7 @@ pub fn process_player_input(
mut save_load: Option<ResMut<SaveLoadPending>>,
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
door_states: Query<&DoorState>,
object_types: Query<&ObjectType>,
) {
@@ -127,6 +129,7 @@ pub fn process_player_input(
| PlayerAction::ChangeSetting { .. }
| PlayerAction::RequestAllSettings
| PlayerAction::DeleteSetting { .. }
| PlayerAction::EconStateQuery { .. }
)
{
continue;
@@ -410,6 +413,13 @@ pub fn process_player_input(
);
}
}
PlayerAction::EconStateQuery { system_id } => {
if let Some(ref mut buf) = econ_query_buf {
buf.pending = Some(system_id);
} else {
tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded");
}
}
}
}
+23
View File
@@ -8,6 +8,7 @@ pub mod chunk_streaming;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
pub mod economy;
pub mod examine;
pub mod follow;
pub mod generator;
@@ -166,6 +167,28 @@ impl Plugin for SimulationPlugin {
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
app.init_resource::<ticker::TickerPool>();
// Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent.
// Uses seed 0 for now; will be threaded through StartupMessage world seed (#826).
if let Some((econ_sim, econ_state)) = economy::try_load_economy(0) {
app.insert_resource(econ_sim).insert_resource(econ_state);
}
// EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted
// even when the economy DB is absent (queries just produce no response).
app.init_resource::<economy::EconQueryBuffer>();
// tick_economy_simulation + serve_econ_state_query use Option<ResMut<...>> — safe to
// register unconditionally. They no-op when EconSimResource / EconStateResource absent.
app.add_systems(
Update,
(
economy::tick_economy_simulation
.after(time::advance_tick)
.before(crate::perception::observer::compute_observer_snapshot),
economy::serve_econ_state_query
.after(economy::tick_economy_simulation)
.before(crate::perception::observer::compute_observer_snapshot),
),
);
tracing::debug!("SimulationPlugin initialized");
}
}
+1 -1
View File
@@ -360,7 +360,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 20,
PROTOCOL_VERSION, 21,
"bump this assertion when protocol version changes"
);
}
+4
View File
@@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2021"
description = "Settled Reach economics simulation — Layer 1 Leontief production + price adjustment"
[lib]
name = "econ_sim"
path = "src/lib.rs"
[[bin]]
name = "econ-sim"
path = "src/main.rs"
+3 -55
View File
@@ -15,64 +15,12 @@
//! Parameters apply to per-corp production in each simulation tick.
//! Trade-layer archetype effects (corp-level bid/ask) are deferred to a
//! future sprint when the event port (D-180) and IPC bridge are in place.
//!
//! The D-180 event port stubs previously in this file have been replaced by
//! the full implementation in `events.rs`.
use std::collections::BTreeMap;
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
+10
View File
@@ -134,6 +134,16 @@ impl CurrencyState {
self.net_cross_zone_flow = 0.0; // reset accumulator for next tick
}
/// Apply an additive exchange rate delta from an `ExchangeShock` event (D-180).
///
/// The result is clamped to the hard bounds `[FX_RATE_MIN, FX_RATE_MAX]`.
pub fn apply_exchange_shock(&mut self, delta: f64) {
if delta != 0.0 {
self.tractus_mark_rate =
(self.tractus_mark_rate + delta).clamp(FX_RATE_MIN, FX_RATE_MAX);
}
}
/// Transport cost factor from `from_zone` to `to_zone`.
///
/// Cross-zone (TRACTUS ↔ MARK) incurs an additional 3% friction.
+387
View File
@@ -0,0 +1,387 @@
//! D-180: Event input port for the economics simulation.
//!
//! External disruptions enter the simulation through `EconEvent` structs
//! pushed into an `EventPort`. Active events are applied each tick via
//! `compute_modifiers()`, which builds combined multiplier maps consumed
//! by the simulation step.
//!
//! ## Lifecycle
//!
//! ```text
//! port.activate_scheduled(tick) // inject any events due this tick
//! let mods = port.compute_modifiers(&economy)
//! step_inner(..., &mods) // apply production/demand/capacity mods
//! currency.apply_exchange_shock(mods.exchange_shock)
//! port.advance_remaining() // decrement and expire finished events
//! ```
//!
//! ## Visibility modes (D-180)
//!
//! Phase 2 exercises `Global` and `Proximate` only.
//! `Hidden` is implemented but not exercised until the player inspect verb
//! exists (Phase 3).
//!
//! ## Economics is a receiver, not an emitter (D-180)
//!
//! The economics layer accepts events; it does NOT generate them.
//! Drama comes from the storyteller, political, or disaster layers.
use std::collections::BTreeMap;
use crate::db::Economy;
// ---------------------------------------------------------------------------
// Event types (D-180)
// ---------------------------------------------------------------------------
/// The scope of nodes affected by an economic event.
#[derive(Debug, Clone)]
pub enum EconEventTarget {
/// A single market node (system_id).
Node(String),
/// An explicit set of market nodes.
// Used by debug command handler (#823) and storyteller layer (#821+):
#[allow(dead_code)]
NodeSet(Vec<String>),
/// All systems in a named cultural corridor.
// Used by storyteller / disaster layer (#821+):
#[allow(dead_code)]
Corridor(String),
/// Both endpoints of a gate link (directed: from → to).
// Used by trade disruption events (#821+):
#[allow(dead_code)]
TradeRoute { from: String, to: String },
/// All systems in a currency zone (`"TRACTUS_PRIMARY"`, `"MARK_PRIMARY"`, `"MIXED"`).
// Used by currency-zone events (#821+):
#[allow(dead_code)]
Currency(String),
/// A specific commodity at all active nodes.
// Used by supply chain disruption events (#821+):
#[allow(dead_code)]
Commodity(String),
}
/// The economic effect applied at the targeted nodes.
#[derive(Debug, Clone)]
pub enum EconEventEffect {
/// Multiply per-corp productivity (`prod.for_tier()`) by this factor.
/// `< 1.0` = disruption; `> 1.0` = boom.
// Used by #823 (debug commands) and storyteller events (#821+):
#[allow(dead_code)]
ProductivityMultiplier(f64),
/// Multiply production capacity (`BASELINE_CAPACITY`) by this factor.
/// `< 1.0` = capacity constraint; `> 1.0` = expanded capacity.
CapacityMultiplier(f64),
/// Multiply consumer demand by this factor at affected nodes.
/// `> 1.0` = demand spike; `< 1.0` = demand collapse.
// Used by #823 (debug commands) and storyteller events (#821+):
#[allow(dead_code)]
DemandShock(f64),
/// Additive delta applied to the Tractus/Mark exchange rate each tick.
/// Positive = Tractus strengthens (Mark weakens).
// Used by #823 (debug commands) and currency events (#821+):
#[allow(dead_code)]
ExchangeShock(f64),
}
/// Who can observe this event (D-180 visibility modes).
#[derive(Debug, Clone)]
pub enum EconEventVisibility {
/// All actors know immediately.
Global,
/// Visible to nodes within N gate hops of the target.
// Used by Proximate event propagation (#821+):
#[allow(dead_code)]
Proximate(u32),
/// Only the named system IDs are informed.
// Used by intel/corporate disclosure events (#821+):
#[allow(dead_code)]
Disclosed(Vec<String>),
/// Creates observable price effects but no knowledge flag.
/// No actor knows the cause. Phase 3 only — requires player inspect verb.
// Used by hidden disruption events (Phase 3, #831+):
#[allow(dead_code)]
Hidden,
}
/// A typed economic disruption event (D-180).
///
/// Push into an `EventPort` via `push()` (immediate) or `push_at()` (scheduled).
#[derive(Debug, Clone)]
pub struct EconEvent {
pub target: EconEventTarget,
pub effect: EconEventEffect,
/// Duration in simulation ticks (clamped to ≥ 1 on push).
pub duration: u32,
/// Who can observe this event. Used by the information boundary system (#822+).
#[allow(dead_code)]
pub visibility: EconEventVisibility,
}
// ---------------------------------------------------------------------------
// EventPort
// ---------------------------------------------------------------------------
struct ActiveEvent {
event: EconEvent,
/// Ticks remaining before this event expires.
remaining_ticks: u32,
}
struct ScheduledEvent {
/// The simulation tick at which to activate this event.
inject_at_tick: u64,
event: EconEvent,
}
/// The event input port — a typed queue of active and scheduled disruptions.
///
/// **Usage in the tick loop:**
/// 1. Call `activate_scheduled(tick)` at the START of each tick.
/// 2. Call `compute_modifiers(&economy)` to get this tick's modifier maps.
/// 3. Pass the modifiers to `step_inner`.
/// 4. Call `advance_remaining()` at the END of each tick.
#[derive(Default)]
pub struct EventPort {
active: Vec<ActiveEvent>,
scheduled: Vec<ScheduledEvent>,
}
impl EventPort {
pub fn new() -> Self {
Self::default()
}
/// Inject an event that starts at the current tick.
pub fn push(&mut self, event: EconEvent) {
let remaining = event.duration.max(1);
self.active.push(ActiveEvent {
event,
remaining_ticks: remaining,
});
}
/// Schedule an event to be injected at a specific simulation tick.
///
/// The event becomes active at the START of `inject_at_tick`, before
/// `compute_modifiers` is called for that tick.
pub fn push_at(&mut self, inject_at_tick: u64, event: EconEvent) {
self.scheduled.push(ScheduledEvent {
inject_at_tick,
event,
});
}
/// Activate any events scheduled for `current_tick`.
///
/// Call at the START of each tick, before `compute_modifiers`.
pub fn activate_scheduled(&mut self, current_tick: u64) {
// Stable Rust: partition scheduled list manually (no drain_filter).
let mut still_pending = Vec::new();
let mut to_activate = Vec::new();
for se in self.scheduled.drain(..) {
if se.inject_at_tick <= current_tick {
to_activate.push(se.event);
} else {
still_pending.push(se);
}
}
self.scheduled = still_pending;
for event in to_activate {
self.push(event);
}
}
/// Decrement remaining ticks and remove events that have expired.
///
/// Call at the END of each tick, after effects have been applied.
pub fn advance_remaining(&mut self) {
for ae in &mut self.active {
ae.remaining_ticks = ae.remaining_ticks.saturating_sub(1);
}
self.active.retain(|ae| ae.remaining_ticks > 0);
}
/// True when no events are active or scheduled.
// Used by tick loop optimization in #821:
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.active.is_empty() && self.scheduled.is_empty()
}
/// Compute combined modifier maps from all currently active events.
///
/// Multiple overlapping events compound multiplicatively for `f64` effects.
/// Exchange shocks accumulate additively.
pub fn compute_modifiers(&self, economy: &Economy) -> EventModifiers {
let mut mods = EventModifiers::default();
for ae in &self.active {
let commodity_filter: Option<String> = match &ae.event.target {
EconEventTarget::Commodity(cid) => Some(cid.clone()),
_ => None,
};
let affected_nodes = resolve_target_nodes(economy, &ae.event.target);
match ae.event.effect {
EconEventEffect::DemandShock(f) => {
for node_id in &affected_nodes {
apply_multiplier(
&mut mods.demand,
node_id,
&commodity_filter,
economy,
f,
);
}
}
EconEventEffect::ProductivityMultiplier(f) => {
for node_id in &affected_nodes {
apply_multiplier(
&mut mods.productivity,
node_id,
&commodity_filter,
economy,
f,
);
}
}
EconEventEffect::CapacityMultiplier(f) => {
for node_id in &affected_nodes {
apply_multiplier(
&mut mods.capacity,
node_id,
&commodity_filter,
economy,
f,
);
}
}
EconEventEffect::ExchangeShock(delta) => {
mods.exchange_shock += delta;
}
}
}
mods
}
}
// ---------------------------------------------------------------------------
// EventModifiers
// ---------------------------------------------------------------------------
/// Combined per-tick modifiers from all currently active events.
///
/// Missing entries default to `1.0` (multiplicative identity) via the `_for` methods.
#[derive(Debug, Default)]
pub struct EventModifiers {
/// `(system_id, commodity_id)` → combined demand multiplier (product of all active shocks).
pub demand: BTreeMap<(String, String), f64>,
/// `(system_id, commodity_id)` → combined productivity multiplier.
pub productivity: BTreeMap<(String, String), f64>,
/// `(system_id, commodity_id)` → combined capacity multiplier.
pub capacity: BTreeMap<(String, String), f64>,
/// Additive delta applied to the Tractus/Mark exchange rate this tick.
pub exchange_shock: f64,
}
impl EventModifiers {
/// Combined demand multiplier for `(system_id, commodity_id)`.
/// Returns `1.0` if no active demand shock targets this pair.
/// Guards String allocation: fast-path returns 1.0 when no demand events are active.
pub fn demand_for(&self, system_id: &str, commodity_id: &str) -> f64 {
if self.demand.is_empty() {
return 1.0;
}
*self
.demand
.get(&(system_id.to_string(), commodity_id.to_string()))
.unwrap_or(&1.0)
}
/// Combined productivity multiplier for `(system_id, commodity_id)`.
/// Guards String allocation: fast-path returns 1.0 when no productivity events are active.
pub fn productivity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
if self.productivity.is_empty() {
return 1.0;
}
*self
.productivity
.get(&(system_id.to_string(), commodity_id.to_string()))
.unwrap_or(&1.0)
}
/// Combined capacity multiplier for `(system_id, commodity_id)`.
/// Guards String allocation: fast-path returns 1.0 when no capacity events are active.
pub fn capacity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
if self.capacity.is_empty() {
return 1.0;
}
*self
.capacity
.get(&(system_id.to_string(), commodity_id.to_string()))
.unwrap_or(&1.0)
}
/// True when no events are affecting this tick (all maps empty, no exchange shock).
// Used by tick loop fast path in #821:
#[allow(dead_code)]
pub fn is_identity(&self) -> bool {
self.demand.is_empty()
&& self.productivity.is_empty()
&& self.capacity.is_empty()
&& self.exchange_shock == 0.0
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Resolve which system IDs are affected by the given event target.
fn resolve_target_nodes(economy: &Economy, target: &EconEventTarget) -> Vec<String> {
match target {
EconEventTarget::Node(id) => vec![id.clone()],
EconEventTarget::NodeSet(ids) => ids.clone(),
EconEventTarget::Corridor(corridor) => economy
.systems
.values()
.filter(|s| s.cultural_corridor.as_deref() == Some(corridor.as_str()))
.map(|s| s.system_id.clone())
.collect(),
EconEventTarget::TradeRoute { from, to } => vec![from.clone(), to.clone()],
EconEventTarget::Currency(zone) => economy
.systems
.values()
.filter(|s| &s.currency_zone == zone)
.map(|s| s.system_id.clone())
.collect(),
// Commodity target: effect applies to this commodity at all active nodes.
// The commodity filter is applied during apply_multiplier.
EconEventTarget::Commodity(_) => economy.systems.keys().cloned().collect(),
}
}
/// Apply a multiplier to all `(node, commodity)` pairs matching the filter.
///
/// If `commodity_filter` is `None`, applies to ALL commodities at `node_id`.
/// Multiple events compound multiplicatively.
fn apply_multiplier(
map: &mut BTreeMap<(String, String), f64>,
node_id: &str,
commodity_filter: &Option<String>,
economy: &Economy,
factor: f64,
) {
let commodity_ids: Vec<String> = match commodity_filter {
Some(cid) => vec![cid.clone()],
None => economy.commodities.iter().map(|c| c.id.clone()).collect(),
};
for cid in commodity_ids {
let entry = map
.entry((node_id.to_string(), cid))
.or_insert(1.0);
*entry *= factor;
}
}
+30
View File
@@ -0,0 +1,30 @@
//! econ_sim — Settled Reach economics simulation library.
//!
//! Exposes the Layer 1+2+3 tâtonnement simulation as a reusable library crate.
//! The standalone `econ-sim` binary uses the same modules independently.
//!
//! ## Entry points
//!
//! - [`Simulation`] — stateful per-tick runner for server integration (#821).
//! Initialize once, call `step()` each economy tick.
//!
//! - [`model::run_with_events`] — batch runner (runs N ticks, returns TickRecords).
//! Used by the standalone binary and stability checks.
//!
//! ## Key decisions
//!
//! - D-178: Economic model architecture (Leontief + tâtonnement + agents)
//! - D-180: Event input port (`EconEvent`, `EventPort`)
//! - D-181: 7-signal vocabulary per active node
pub mod agents;
pub mod currency;
pub mod db;
pub mod events;
pub mod model;
pub mod prng;
pub mod seed;
pub mod trade;
mod sim;
pub use sim::Simulation;
+130 -41
View File
@@ -21,6 +21,7 @@ use clap::Parser;
mod agents;
mod currency;
mod db;
mod events;
mod model;
mod output;
mod prng;
@@ -260,11 +261,11 @@ fn run_stability_checks(
}
// -----------------------------------------------------------------
// 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.
// Test 3: D-179 shock response — inject supply shock, verify cascade
// and recovery within 200 ticks (D-179 Test 3, D-180 event port).
// -----------------------------------------------------------------
let (test3_pass, test3_note) = run_no_explosion_check(economy, &records);
let (test3_pass, test3_note) =
run_shock_response_test(economy, productivity, shadow, adjacency);
// -----------------------------------------------------------------
// Test 4: cross-zone balance (skip if no MARK_PRIMARY systems)
@@ -307,7 +308,7 @@ fn run_stability_checks(
.unwrap_or_default()
);
eprintln!(
"Test 3 (no-explosion check — price bounds over 1000 ticks): {} {}",
"Test 3 (shock response — D-180 CapacityMult event, recovery ≤200 ticks): {} {}",
sym(test3_pass),
test3_note
);
@@ -327,60 +328,148 @@ fn run_stability_checks(
}
}
/// Verify no price explosions or negative prices in the 1000-tick run.
/// D-179 Test 3: shock response — inject supply disruption, verify cascade and recovery.
///
/// 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(
/// Protocol:
/// 1. Run WARMUP_TICKS with no events to establish a stable price baseline.
/// 2. At tick WARMUP_TICKS, inject a `CapacityMultiplier(0.1)` event on the
/// most active node for SHOCK_DURATION ticks (90% capacity reduction).
/// 3. Continue for RECOVERY_WINDOW ticks after the shock expires.
/// 4. Verify: no price explosion (>20× base) at any tick.
/// 5. Verify: all prices at end of recovery ≤ ±5% of the pre-shock baseline.
///
/// A `CapacityMultiplier(0.1)` supply disruption is severe enough to deplete
/// stockpiles and propagate price signals to neighboring nodes (cascade),
/// while remaining recoverable within the 200-tick window (recovery).
fn run_shock_response_test(
economy: &db::Economy,
records_1000: &[model::TickRecord],
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
shadow: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) -> (bool, String) {
const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price
use std::collections::BTreeMap;
// Check: no price > 20× base at any tick
let mut explosion_detected = false;
let mut explosion_worst = String::new();
for r in records_1000 {
const WARMUP_TICKS: u32 = 100;
const SHOCK_DURATION: u32 = 50;
const RECOVERY_WINDOW: u32 = 200;
const RECOVERY_THRESHOLD: f64 = 0.05; // ±5% of pre-shock baseline
// Pick the first active node (has corp presence) as the shock target
let shock_node = economy
.presences_by_system
.keys()
.next()
.cloned()
.or_else(|| {
economy
.systems
.values()
.find(|s| s.population > 0)
.map(|s| s.system_id.clone())
});
let shock_node = match shock_node {
Some(n) => n,
None => return (true, "SKIP — no active nodes for shock test".to_string()),
};
// Schedule: inject 90% capacity disruption at tick WARMUP_TICKS
let mut port = events::EventPort::new();
port.push_at(
WARMUP_TICKS as u64,
events::EconEvent {
target: events::EconEventTarget::Node(shock_node.clone()),
effect: events::EconEventEffect::CapacityMultiplier(0.1),
duration: SHOCK_DURATION,
visibility: events::EconEventVisibility::Global,
},
);
let total_ticks = WARMUP_TICKS + SHOCK_DURATION + RECOVERY_WINDOW;
let records = model::run_with_events(
economy,
productivity,
shadow,
adjacency,
total_ticks,
&mut port,
);
// Index records by (node_id, commodity_id, tick) for lookups
let baseline: BTreeMap<(String, String), f64> = records
.iter()
.filter(|r| r.tick == WARMUP_TICKS - 1)
.map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price))
.collect();
// Check 1: no price explosions or negatives at any tick
const PRICE_EXPLOSION_LIMIT: f64 = 20.0;
for r in &records {
let base = economy
.commodity_map
.get(&r.commodity_id)
.map_or(1.0, |c| c.base_price);
if r.price > base * PRICE_EXPLOSION_LIMIT {
explosion_detected = true;
explosion_worst = format!(
"{}/{} price={:.1} base={:.1} ({:.0}×)",
r.node_id,
r.commodity_id,
r.price,
base,
r.price / base
return (
false,
format!(
"price explosion at tick {}: {}/{} price={:.1} ({:.0}×base)",
r.tick,
r.node_id,
r.commodity_id,
r.price,
r.price / base
),
);
}
if r.price < 0.0 {
return (
false,
format!(
"negative price at tick {}: {}/{} price={:.4}",
r.tick, r.node_id, r.commodity_id, r.price
),
);
}
}
if explosion_detected {
return (false, format!("price explosion: {}", explosion_worst));
}
// Check 2: prices at end of recovery window are within ±5% of pre-shock baseline
let recovery_end_tick = total_ticks - 1;
let recovery_prices: BTreeMap<(String, String), f64> = records
.iter()
.filter(|r| r.tick == recovery_end_tick)
.map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price))
.collect();
// 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
),
);
let mut worst_dev: f64 = 0.0;
let mut worst_key = String::new();
for ((node_id, commodity_id), &baseline_price) in &baseline {
if baseline_price < 1e-9 {
continue;
}
let key = (node_id.clone(), commodity_id.clone());
if let Some(&recovery_price) = recovery_prices.get(&key) {
let dev = (recovery_price - baseline_price).abs() / baseline_price;
if dev > worst_dev {
worst_dev = dev;
worst_key = format!("{node_id}/{commodity_id}");
}
}
}
let pass = worst_dev <= RECOVERY_THRESHOLD;
(
true,
pass,
format!(
"no explosions (>{:.0}× base), no negatives across {} records",
PRICE_EXPLOSION_LIMIT,
records_1000.len()
"CapacityMult(0.1)×{SHOCK_DURATION}t on {shock_node} at t={WARMUP_TICKS}, \
max_dev={:.1}% at t={recovery_end_tick} (threshold ±5%){}",
worst_dev * 100.0,
if !worst_key.is_empty() {
format!(" worst: {worst_key}")
} else {
String::new()
}
),
)
}
+90 -21
View File
@@ -1,19 +1,23 @@
//! Layer 1: Leontief production + consumption + price adjustment.
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
//! Layer 3: Corporate behavioral agents (D-178) — added in #809.
//!
//! Each system with economic activity (corp presence or population > 0)
//! is an active market node. Goods flow along gate links when price
//! differentials exceed transport costs (α=0.03, β=0.4).
//!
//! Layer 3 (corporate behavioral agents) is added in #809.
//! Event port (D-180) — added in #810:
//! External disruptions enter via `EventPort` passed to `run_with_events`.
//! `run()` is the no-event fast path (delegates to `run_with_events`).
//!
//! Reference: D-178 (Economic Model Architecture)
//! Reference: D-178 (Economic Model Architecture), D-180 (Event Input Port)
use std::collections::BTreeMap;
use crate::agents;
use crate::currency::{CurrencyState, ShadowEconomy};
use crate::db::Economy;
use crate::events::{EventModifiers, EventPort};
use crate::seed::Productivity;
use crate::trade;
@@ -22,7 +26,8 @@ use crate::trade;
// ---------------------------------------------------------------------------
/// Price adjustment rate per tick (α=0.03, D-178 Layer 2).
const ALPHA: f64 = 0.03;
/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823).
pub const ALPHA: f64 = 0.03;
/// Baseline production capacity per corp per tick (units/tick).
const BASELINE_CAPACITY: f64 = 10.0;
@@ -80,11 +85,10 @@ pub struct TickRecord {
// Simulation
// ---------------------------------------------------------------------------
/// Run the Layer 1+2 simulation for `ticks` ticks.
/// Run the Layer 1+2+3 simulation for `ticks` ticks (no external events).
///
/// 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).
/// Fast path: delegates to `run_with_events` with an empty `EventPort`.
/// Use `run_with_events` when event injection is required (D-180 tests, debug).
///
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
pub fn run(
@@ -93,6 +97,36 @@ pub fn run(
shadow: &ShadowEconomy,
adjacency: &BTreeMap<String, Vec<String>>,
ticks: u32,
) -> Vec<TickRecord> {
let mut port = EventPort::new();
run_with_events(economy, productivity, shadow, adjacency, ticks, &mut port)
}
/// Run the Layer 1+2+3 simulation with D-180 event injection.
///
/// Layer 1: Leontief production + consumption + stockpile update.
/// Layer 2: Damped tâtonnement trade flows along gate links (D-178).
/// Currency zone friction and exchange rate adjustment (D-171, D-172).
/// Layer 3: Corporate behavioral archetypes (D-178).
/// Events: external disruptions applied each tick (D-180).
///
/// Tick loop invariant:
/// 1. `events.activate_scheduled(tick)` — inject events due this tick.
/// 2. `events.compute_modifiers()` → modifier maps for this tick.
/// 3. `step_inner` — production + demand + price adjustment with modifiers.
/// 4. `currency.apply_exchange_shock` — apply any exchange shock from events.
/// 5. `trade_step` — inter-node trade flows.
/// 6. `currency.update_rate` — FX adjustment from net cross-zone flow.
/// 7. `events.advance_remaining` — decrement and expire finished events.
///
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
pub fn run_with_events(
economy: &Economy,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
adjacency: &BTreeMap<String, Vec<String>>,
ticks: u32,
events: &mut EventPort,
) -> Vec<TickRecord> {
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
let mut nodes = init_nodes(economy);
@@ -100,10 +134,18 @@ pub fn run(
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);
// Activate any events scheduled for this tick (D-180)
events.activate_scheduled(tick as u64);
let mods = events.compute_modifiers(economy);
step_inner(economy, productivity, shadow, &archetypes, &mut nodes, &mods, ALPHA);
currency.apply_exchange_shock(mods.exchange_shock);
trade::trade_step(economy, &mut nodes, adjacency, &mut currency, trade::BETA);
currency.update_rate();
// Expire events that have completed their duration
events.advance_remaining();
let fx_rate = currency.tractus_mark_rate;
for node in nodes.values() {
let node_shadow = shadow
@@ -133,7 +175,11 @@ pub fn run(
// Initialization
// ---------------------------------------------------------------------------
fn init_nodes(economy: &Economy) -> BTreeMap<String, NodeState> {
/// Initialize node states for all active systems (corp presence or population > 0).
///
/// Public for use by [`crate::sim::Simulation`] and external callers that need
/// a stateful simulation runner rather than the batch `run_with_events` API.
pub fn init_nodes(economy: &Economy) -> BTreeMap<String, NodeState> {
let mut nodes: BTreeMap<String, NodeState> = BTreeMap::new();
// Activate nodes that have corp presence or non-zero population
@@ -199,12 +245,20 @@ fn base_population_demand(population: i64, tier: &str) -> f64 {
/// At 100% intensity, shadow goods meet up to this fraction of demand.
const SHADOW_DEMAND_COVERAGE: f64 = 0.30;
fn step(
/// Single simulation tick: Layer 1 production + demand + price adjustment.
///
/// `event_mods` carries per-(node, commodity) multipliers from active D-180 events.
/// Pass `&EventModifiers::default()` when no events are active.
///
/// Public for use by [`crate::sim::Simulation`] and external stateful runners.
pub fn step_inner(
economy: &Economy,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
archetypes: &BTreeMap<String, agents::Archetype>,
nodes: &mut BTreeMap<String, NodeState>,
event_mods: &EventModifiers,
alpha: f64,
) {
// Process each active node independently (Layer 1: no inter-system trade)
let system_ids: Vec<String> = nodes.keys().cloned().collect();
@@ -247,8 +301,10 @@ fn step(
.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;
// D-180: capacity multiplier from active events (1.0 if no event)
let cap_mult = event_mods.capacity_for(system_id.as_str(), &primary_op);
// Effective baseline = BASELINE_CAPACITY scaled by archetype and event
let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale * cap_mult;
// Determine the tier of the primary_operation commodity
let tier = economy
@@ -259,7 +315,10 @@ fn step(
if tier == "raw" {
// Raw materials: direct extraction — no chain inputs required (D-177).
let gross_output = effective_capacity * prod.extraction_rate;
// D-180: productivity multiplier from active events (1.0 if no event)
let prod_mult_event =
event_mods.productivity_for(system_id.as_str(), &primary_op);
let gross_output = effective_capacity * prod.extraction_rate * prod_mult_event;
// Monopolist withholds a fraction of output
let net_output = gross_output * (1.0 - arch_params.supply_withheld);
if let Some(state) = node.commodities.get_mut(&primary_op) {
@@ -289,10 +348,15 @@ fn step(
}
}
// Apply productivity multiplier
// Apply productivity multipliers (seeded + event)
let prod_mult = prod.for_tier(&chain_output_tier(economy, chain));
let gross_output =
effective_capacity * chain.output_quantity * capacity_fraction * prod_mult;
let prod_mult_event = event_mods
.productivity_for(system_id.as_str(), &chain.output_commodity_id);
let gross_output = effective_capacity
* chain.output_quantity
* capacity_fraction
* prod_mult
* prod_mult_event;
let net_output = gross_output * (1.0 - arch_params.supply_withheld);
// Consume inputs (Leontief: fixed-coefficient deduction)
@@ -319,7 +383,7 @@ fn step(
.commodity_map
.get(&primary_op)
.map_or(1.0, |c| c.base_price);
let nudge = base_price * arch_params.price_premium * ALPHA;
let nudge = base_price * arch_params.price_premium * alpha;
state.price = (state.price + nudge).clamp(base_price * 0.05, base_price * 20.0);
}
}
@@ -331,6 +395,8 @@ fn step(
//
// Shadow economy (D-174): shadow goods satisfy a fraction of formal demand,
// reducing formal-sector stockpile consumption proportionally.
//
// D-180: DemandShock events multiply demand further (or compress it).
let shadow_intensity = shadow.intensity.get(system_id).copied().unwrap_or(0.0);
let shadow_coverage = shadow_intensity * SHADOW_DEMAND_COVERAGE;
@@ -338,7 +404,7 @@ fn step(
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"
let gate_reduced = if commodity.id == "fusion_fuel"
&& system_info.gate_energy_connected
&& commodity.tier != "raw"
{
@@ -347,8 +413,11 @@ fn step(
base_demand
};
// D-180: demand shock multiplier from active events (1.0 if no event)
let demand_mult = event_mods.demand_for(system_id.as_str(), &commodity.id);
// Shadow economy reduces formal-sector consumption (some demand met off-books)
let demand = raw_demand * (1.0 - shadow_coverage);
let demand = gate_reduced * demand_mult * (1.0 - shadow_coverage);
if let Some(state) = node.commodities.get_mut(&commodity.id) {
state.demand = demand;
@@ -383,7 +452,7 @@ fn step(
};
state.price =
(state.price * (1.0 - ALPHA * excess)).clamp(base_price * 0.05, base_price * 20.0);
(state.price * (1.0 - alpha * excess)).clamp(base_price * 0.05, base_price * 20.0);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Stateful simulation runner for server integration (#821).
//!
//! [`Simulation`] wraps all simulation state (economy data, node states,
//! currency, events) and exposes a per-tick `step()` method. This is the
//! entry point for the game server's economy system, which advances one
//! economy tick per ECON_TICK_RATE game ticks (D-031).
//!
//! The batch `model::run_with_events` is retained for the CLI binary and
//! stability checks. Both share the same underlying `model::step_inner`.
use std::collections::BTreeMap;
use std::path::Path;
use crate::{agents, currency, db, events, model, seed, trade};
/// Stateful Settled Reach economics simulation.
///
/// Initialize with [`Simulation::load`] once at server startup.
/// Call [`Simulation::step`] once per economy tick.
pub struct Simulation {
pub economy: db::Economy,
productivity: BTreeMap<(String, String), seed::Productivity>,
shadow: currency::ShadowEconomy,
adjacency: BTreeMap<String, Vec<String>>,
archetypes: BTreeMap<String, agents::Archetype>,
pub nodes: BTreeMap<String, model::NodeState>,
currency_state: currency::CurrencyState,
/// The event input port (D-180). Push events here; they are consumed
/// on the next `step()` call.
pub events: events::EventPort,
/// Number of economy ticks processed so far.
tick: u64,
/// Tâtonnement step size (α). Runtime-tunable via SetEconParam (#823).
/// Default: `model::ALPHA` (0.03).
pub alpha: f64,
/// Trade flow damping factor (β). Runtime-tunable via SetEconParam (#823).
/// Default: `trade::BETA` (0.4).
pub beta: f64,
}
impl Simulation {
/// Load economy data from `db_path` and initialize the simulation.
///
/// `run_seed` is the per-run PRNG seed for productivity seeding (D-176).
/// This is typically the game's world seed from `StartupMessage`.
///
/// The DB is opened once and the loaded data stored in memory.
/// Do NOT call this per tick.
pub fn load(db_path: &Path, run_seed: u64) -> Result<Self, String> {
let db_pathbuf = db_path.to_path_buf();
if !db_path.exists() {
return Err(format!("economy DB not found: {}", db_path.display()));
}
let conn = db::open_db(&db_pathbuf);
let economy = db::load_economy(&conn);
let productivity = seed::seed_all_productivity(&economy, run_seed);
let shadow = currency::seed_shadow_economy(&economy, run_seed);
let adjacency = trade::build_adjacency(&economy);
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
let nodes = model::init_nodes(&economy);
Ok(Simulation {
economy,
productivity,
shadow,
adjacency,
archetypes,
nodes,
currency_state: currency::CurrencyState::new(),
events: events::EventPort::new(),
tick: 0,
alpha: model::ALPHA,
beta: trade::BETA,
})
}
/// Try to load from the auto-detected DB path (same search as the CLI binary).
///
/// Searches up from CWD for `server/data/systems.db`.
pub fn load_auto(run_seed: u64) -> Result<Self, String> {
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
loop {
let candidate = dir.join("server").join("data").join("systems.db");
if candidate.exists() {
return Self::load(&candidate, run_seed);
}
if !dir.pop() {
break;
}
}
// Also check adjacent `data/` directory (when running from within server/)
let candidate = std::path::PathBuf::from("data").join("systems.db");
if candidate.exists() {
return Self::load(&candidate, run_seed);
}
Err("cannot find server/data/systems.db — pass path explicitly or run from project root".to_string())
}
/// Advance the simulation by one economy tick.
///
/// Applies active events, runs the Layer 1+2+3 step, and advances the
/// event port. Call once per economy tick (every ECON_TICK_RATE game ticks).
pub fn step(&mut self) {
// Activate any events scheduled for this tick (D-180)
self.events.activate_scheduled(self.tick);
let mods = self.events.compute_modifiers(&self.economy);
model::step_inner(
&self.economy,
&self.productivity,
&self.shadow,
&self.archetypes,
&mut self.nodes,
&mods,
self.alpha,
);
self.currency_state.apply_exchange_shock(mods.exchange_shock);
trade::trade_step(
&self.economy,
&mut self.nodes,
&self.adjacency,
&mut self.currency_state,
self.beta,
);
self.currency_state.update_rate();
// Expire finished events
self.events.advance_remaining();
self.tick += 1;
}
/// Number of economy ticks processed so far.
pub fn tick(&self) -> u64 {
self.tick
}
/// Current Tractus/Mark exchange rate.
pub fn tractus_mark_rate(&self) -> f64 {
self.currency_state.tractus_mark_rate
}
/// Read-only access to the loaded economy data.
pub fn economy(&self) -> &db::Economy {
&self.economy
}
/// Read-only access to the per-node shadow economy intensities.
pub fn shadow(&self) -> &currency::ShadowEconomy {
&self.shadow
}
}
+4 -2
View File
@@ -27,7 +27,8 @@ 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;
/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823).
pub const BETA: f64 = 0.4;
/// Maximum fraction of a node's stockpile exported per tick via a single link.
/// Limits shock propagation speed.
@@ -73,6 +74,7 @@ pub fn trade_step(
nodes: &mut BTreeMap<String, NodeState>,
adjacency: &BTreeMap<String, Vec<String>>,
currency: &mut CurrencyState,
beta: f64,
) {
// Collect pending flows before mutating (snapshot prices/stockpiles first)
// (from_system, to_system, commodity_id, amount, cross_zone_tractus_to_mark)
@@ -131,7 +133,7 @@ pub fn trade_step(
// 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;
let flow = beta * price_ratio * max_export;
if flow > 1e-6 {
flows.push((
+44 -53
View File
@@ -7,8 +7,8 @@ Run from any directory — paths are resolved relative to this script's location
Sources:
docs/design/star-map.json — graph topology (nodes + edges)
server/server/data/systems.db — proper names, geographic sectors
wiki/star-systems/ — star type, bodies, population, GTTR excerpt
server/data/systems.db — proper names, geographic sectors, bodies, GDP tier
wiki/star-systems/ — star type, GTTR excerpt (bodies/population from systems.db)
Output:
client/data/star_map_data.json — self-contained client data for the star map UI
@@ -21,16 +21,11 @@ import sqlite3
import sys
import tempfile
# Resolve project root from this script's location: tooling/ is one level below root.
# Resolve project root from this script's location.
# Works regardless of cwd — no fragile relative path guessing.
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR)
# Worktree layout: settled-reach/{client,server,main}/
# This script lives in client/tooling/, so _PROJECT_ROOT = client/.
# The parent of _PROJECT_ROOT is the worktree parent where sibling dirs live.
_WORKTREE_PARENT = os.path.dirname(_PROJECT_ROOT)
STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json")
SYSTEMS_DB_PATH = os.path.join(_PROJECT_ROOT, "server", "data", "systems.db")
WIKI_PATH = os.path.join(_PROJECT_ROOT, "wiki", "star-systems")
@@ -43,13 +38,14 @@ def system_id_to_wiki_slug(system_id: str) -> str:
def parse_wiki_index(system_id: str) -> dict:
"""Extract star type, bodies summary, and population from index.md.
"""Extract star type from index.md.
Returns dict with keys: star_type, bodies, population (all strings, may be empty).
Bodies and population are authoritative from systems.db — not read from wiki.
Returns dict with key: star_type (string, may be empty).
"""
slug = system_id_to_wiki_slug(system_id)
path = os.path.join(WIKI_PATH, slug, "index.md")
result = {"star_type": "", "bodies": "", "population": ""}
result = {"star_type": ""}
if not os.path.exists(path):
return result
with open(path, encoding="utf-8") as f:
@@ -60,18 +56,7 @@ def parse_wiki_index(system_id: str) -> dict:
if m:
raw = m.group(1).strip()
# Extract spectral class — everything before " ·" or end of string
star_type = raw.split("·")[0].strip()
result["star_type"] = star_type
# Bodies row: | **Bodies** | 2 habitable · 3 inhabited |
m = re.search(r"\|\s*\*\*Bodies\*\*\s*\|\s*([^|]+?)\s*\|", content)
if m:
result["bodies"] = m.group(1).strip()
# Population row: | **Population** | 1,200,000,000 |
m = re.search(r"\|\s*\*\*Population\*\*\s*\|\s*([^|]+?)\s*\|", content)
if m:
result["population"] = m.group(1).strip()
result["star_type"] = raw.split("·")[0].strip()
return result
@@ -173,39 +158,45 @@ def generate() -> dict:
with open(STAR_MAP_PATH) as f:
star_map = json.load(f)
conn = sqlite3.connect(SYSTEMS_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"SELECT system_id, proper_name, geographic_sector, geographic_band "
"FROM star_systems"
)
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
try:
conn = sqlite3.connect(SYSTEMS_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Aggregate body data per system from the bodies table
cur.execute("""
SELECT system_id,
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
FROM bodies
GROUP BY system_id
""")
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
cur.execute(
"SELECT system_id, proper_name, geographic_sector, geographic_band "
"FROM star_systems"
)
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Also sum station populations
cur.execute("""
SELECT system_id,
SUM(COALESCE(population, 0)) AS station_pop
FROM stations
GROUP BY system_id
""")
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Aggregate body data per system from the bodies table
cur.execute("""
SELECT system_id,
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
FROM bodies
GROUP BY system_id
""")
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Economic tier for GDP calculation
cur.execute("SELECT system_id, economic_tier FROM system_economy")
econ_tiers = {row["system_id"]: row["economic_tier"] for row in cur.fetchall()}
conn.close()
# Also sum station populations
cur.execute("""
SELECT system_id,
SUM(COALESCE(population, 0)) AS station_pop
FROM stations
GROUP BY system_id
""")
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Economic tier for GDP calculation
cur.execute("SELECT system_id, economic_tier FROM system_economy")
econ_tiers = {row["system_id"]: row["economic_tier"] for row in cur.fetchall()}
except sqlite3.Error as e:
print(f"ERROR: systems.db query failed: {e}", file=sys.stderr)
sys.exit(1)
finally:
conn.close()
adjacency = build_adjacency(star_map["edges"])
@@ -6,293 +6,263 @@
"rivers": [
{
"id": "river_0",
"name": null,
"name": "The Aldren",
"path": [
[
172,
276
],
[
173,
276
],
[
174,
275
],
[
175,
276
],
[
176,
275
],
[
177,
274
],
[
178,
273
],
[
179,
272
],
[
180,
271
],
[
181,
270
]
[172, 276],
[173, 276],
[174, 275],
[175, 276],
[176, 275],
[177, 274],
[178, 273],
[179, 272],
[180, 271],
[181, 270]
]
},
{
"id": "river_1",
"name": null,
"name": "The Kesset",
"path": [
[
250,
324
],
[
249,
324
],
[
248,
323
],
[
247,
322
],
[
246,
321
],
[
245,
322
],
[
244,
323
],
[
243,
323
],
[
242,
324
],
[
241,
325
],
[
240,
326
]
[250, 324],
[249, 324],
[248, 323],
[247, 322],
[246, 321],
[245, 322],
[244, 323],
[243, 323],
[242, 324],
[241, 325],
[240, 326]
]
},
{
"id": "river_2",
"name": null,
"name": "Pale Run",
"path": [
[
74,
79
],
[
75,
80
],
[
76,
80
],
[
77,
79
],
[
78,
78
],
[
79,
77
],
[
80,
76
],
[
81,
75
],
[
82,
75
]
[74, 79],
[75, 80],
[76, 80],
[77, 79],
[78, 78],
[79, 77],
[80, 76],
[81, 75],
[82, 75]
]
}
],
"oceans": [
{
"id": "water_2",
"name": null,
"name": "Rethain Sea",
"kind": "ocean",
"center": [
133,
403
],
"center": [133, 403],
"area_fraction": 0.1859
},
{
"id": "water_3",
"name": null,
"name": "Lenden Ocean",
"kind": "ocean",
"center": [
169,
124
],
"center": [169, 124],
"area_fraction": 0.2044
},
{
"id": "water_5",
"name": null,
"name": "Corsam Lake",
"kind": "lake",
"center": [
138,
236
],
"center": [138, 236],
"area_fraction": 0.0115
},
{
"id": "water_11",
"name": null,
"name": "Selet Basin",
"kind": "lake",
"center": [
220,
336
],
"center": [220, 336],
"area_fraction": 0.0082
}
],
"mountain_ranges": [
{
"id": "range_1",
"name": null,
"center": [
24,
46
],
"peak": [
20,
44
],
"name": "Durneth Range",
"center": [24, 46],
"peak": [20, 44],
"area_cells": 3904
},
{
"id": "range_2",
"name": null,
"center": [
16,
366
],
"peak": [
14,
361
],
"name": "Brantfell",
"center": [16, 366],
"peak": [14, 361],
"area_cells": 1162
},
{
"id": "range_3",
"name": null,
"center": [
18,
206
],
"peak": [
14,
188
],
"name": "Keslar Spine",
"center": [18, 206],
"peak": [14, 188],
"area_cells": 1294
},
{
"id": "range_4",
"name": null,
"center": [
35,
488
],
"peak": [
22,
511
],
"name": "Pallach Heights",
"center": [35, 488],
"peak": [22, 511],
"area_cells": 1070
},
{
"id": "range_5",
"name": null,
"center": [
34,
305
],
"peak": [
35,
304
],
"name": "Tember Ridge",
"center": [34, 305],
"peak": [35, 304],
"area_cells": 623
},
{
"id": "range_6",
"name": null,
"center": [
33,
396
],
"peak": [
31,
396
],
"name": "Holt Spur",
"center": [33, 396],
"peak": [31, 396],
"area_cells": 208
},
{
"id": "range_7",
"name": null,
"center": [
60,
287
],
"peak": [
62,
290
],
"name": "The Golvane",
"center": [60, 287],
"peak": [62, 290],
"area_cells": 832
},
{
"id": "range_9",
"name": null,
"center": [
56,
245
],
"peak": [
57,
245
],
"name": "Cresswell Scarp",
"center": [56, 245],
"peak": [57, 245],
"area_cells": 76
}
],
"roads": [],
"cities": [],
"railroads": [],
"pois": []
}
"roads": [
{
"id": "road_0",
"name": "Aldren–Sethvale Road",
"kind": "commercial",
"path": [
[145, 235],
[140, 275],
[135, 320],
[132, 365],
[130, 390],
[130, 408]
]
},
{
"id": "road_1",
"name": "Aldren–Caldenmere Road",
"kind": "commercial",
"path": [
[145, 235],
[132, 215],
[118, 192],
[102, 168],
[88, 148]
]
},
{
"id": "road_2",
"name": "Sethvale–Caldenmere Circuit",
"kind": "regional",
"path": [
[130, 408],
[112, 355],
[100, 295],
[93, 220],
[88, 148]
]
}
],
"cities": [
{
"id": "city_0",
"name": "Aldren",
"kind": "capital",
"center": [145, 235],
"population": 250000000
},
{
"id": "city_1",
"name": "Sethvale",
"kind": "city",
"center": [130, 408],
"population": 80000000
},
{
"id": "city_2",
"name": "Caldenmere",
"kind": "city",
"center": [88, 148],
"population": 55000000
}
],
"railroads": [
{
"id": "railroad_0",
"name": "Lendel Express",
"kind": "passenger_freight",
"path": [
[145, 235],
[138, 295],
[132, 352],
[130, 408]
]
},
{
"id": "railroad_1",
"name": "Northern Line",
"kind": "freight",
"path": [
[145, 235],
[122, 200],
[102, 170],
[88, 148]
]
}
],
"pois": [
{
"id": "poi_0",
"name": "Settlement House",
"kind": "institutional",
"center": [144, 234]
},
{
"id": "poi_1",
"name": "The Registry",
"kind": "institutional",
"center": [146, 236]
},
{
"id": "poi_2",
"name": "Meridian Risk HQ",
"kind": "corporate",
"center": [145, 233]
},
{
"id": "poi_3",
"name": "Lattice Commission — Groombridge Office",
"kind": "institutional",
"center": [147, 235]
},
{
"id": "poi_4",
"name": "Aldren Exchange",
"kind": "commercial",
"center": [143, 237]
},
{
"id": "poi_5",
"name": "Groombridge Gate Terminal",
"kind": "transit",
"center": [148, 232]
}
]
}