Compare commits

..
17 Commits
Author SHA1 Message Date
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 Opus 4.6 532fcd64a5 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:22:47 +02:00
jpmschweitzerandClaude Opus 4.6 f17a6c638c feat(ui): economics debug console commands — inject, param, inspect (#825)
Three new econ subcommands in the debug console: inject (supply
shocks/boosts), param (α/β/friction mutation), inspect (all 7
D-181 signals). Command parsing and validation complete; dispatch
wired through existing DebugCommand IPC flow. Server handler
ships with #823.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:22:30 +02:00
jpmschweitzerandClaude Opus 4.6 949d721eac feat(ui): economics monitor insert panel with placeholder data (#824)
New implant panel at implant/economics: system selector, 6-commodity
price table with trend indicators, GDP strip. Composed from D-169
component library. Ring buffer caches last 20 ticks per system.
Snapshot routing wired through snapshot_handler → GameState →
snapshot_consumers → economics_panel. Placeholder prices shown
until server ships EconomySnapshot (#822).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:22:20 +02:00
jpmschweitzerandClaude Opus 4.6 766da969de feat(ui): add system population and GDP to star map info panel (#785)
Star map popup now shows POPULATION and GDP rows when data is present.
Generation script updated to compute GDP from population × tier-based
per-capita schedule. 275/301 systems have GDP data (26 uninhabited
correctly omitted).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 13:22:10 +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
jpmschweitzerandClaude Sonnet 4.6 9d9ea96be1 chore(meta): plan Sprint 34: Pulse
Close Phase 2 — wire econ-sim into game server tick loop, expose price
history and trade flows via implant insert panel, add economics debug
console commands for runtime event injection and parameter mutation.

New tickets: #821 (server tick integration), #822 (IPC bridge v21),
#823 (debug command handler), #824 (economics insert panel),
#825 (debug console econ commands). Existing: #810, #785, #811, #748,
#814, #695. 11 tickets total across server, client, copy, planning.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:20:29 +02:00
41 changed files with 4241 additions and 182 deletions
+6
View File
@@ -6,6 +6,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- 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
- Star map info panel shows system population and GDP when data is available (#785)
- 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)
## [v0.1.33] — 2026-04-08
### Added
File diff suppressed because it is too large Load Diff
+6
View File
@@ -116,6 +116,12 @@ var ai_enhanced_dialogue_enabled: bool = true
# "full" response hydrates ai_enhanced_dialogue_enabled (server is authoritative for persisted state).
var settings_response: Variant = null
# v21 fields (#824, D-181): Economy snapshot from server.
# Dictionary keyed by system_id → { price_current, price_trend, trade_flow_volume,
# corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio }
# Null when no economy data in the current snapshot.
var economy_snapshot: Variant = null
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
+1
View File
@@ -14,6 +14,7 @@ extends Node
## "implant/map/starchart" — star map navigator
## "implant/wiki/gttr" — Drifter's Guide reader
## "implant/journal" — knowledge journal
## "implant/economics" — economics monitor (D-181, #824)
##
## Usage:
## HudGroups.register(self, "implant/map/starchart")
+15 -1
View File
@@ -9,7 +9,6 @@ var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@onready var world_renderer = $World
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
@onready var camera = $Camera2D
@@ -34,6 +33,7 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
@onready var star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view)
@onready var economics_panel = $InsertOverlay/HUD/EconomicsPanel # #824: economics monitor (D-170)
func _ready() -> void:
@@ -92,6 +92,7 @@ func _ready() -> void:
"interaction_prompt": interaction_prompt,
"minimap": minimap,
"star_map": star_map,
"economics_panel": economics_panel,
},
_screen_flash
)
@@ -154,6 +155,7 @@ func _ready() -> void:
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
_router.register("save_result", _consumers.consume_save_result)
_router.register("debug_response", _consumers.consume_debug_response)
_router.register("economy_snapshot", _consumers.consume_economy_snapshot)
# #581: Wire settings_dialog debug console toggle
if settings_dialog and debug_console:
@@ -170,6 +172,18 @@ func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey and event.keycode == KEY_M:
if star_map:
star_map.toggle_visible()
elif event is InputEventKey and event.keycode == KEY_N:
# #824: N — toggle Economics Monitor implant panel (E is bound to interact)
if economics_panel:
economics_panel.toggle_visible()
elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT:
# #824: [ — cycle economics panel system selector backward
if economics_panel and HudGroups.is_app_active("implant/economics"):
economics_panel.navigate(-1)
elif event is InputEventKey and event.keycode == KEY_BRACKETRIGHT:
# #824: ] — cycle economics panel system selector forward
if economics_panel and HudGroups.is_app_active("implant/economics"):
economics_panel.navigate(1)
func _process(delta: float) -> void:
+13
View File
@@ -17,6 +17,7 @@ var interaction_list: Node = null
var interaction_prompt: Node = null
var minimap: Node = null
var star_map: Node = null
var economics_panel: Node = null # #824: economics monitor (D-181)
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
@@ -37,6 +38,7 @@ func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
interaction_prompt = refs.get("interaction_prompt")
minimap = refs.get("minimap")
star_map = refs.get("star_map")
economics_panel = refs.get("economics_panel")
_screen_flash_fn = screen_flash
return self
@@ -54,6 +56,8 @@ func propagate_insert_state() -> void:
minimap.set_insert_active(insert_state)
if star_map:
star_map.set_insert_active(insert_state)
if economics_panel:
economics_panel.set_insert_active(insert_state)
# D-057: Update interaction list from game state.
@@ -173,6 +177,15 @@ func consume_debug_response() -> void:
GameState.debug_response = null
# #824: Forward economy_snapshot from server to the economics panel (D-181).
func consume_economy_snapshot() -> void:
if GameState.economy_snapshot == null or not economics_panel:
return
if economics_panel.has_method("receive_economy_data"):
economics_panel.receive_economy_data(GameState.economy_snapshot)
GameState.economy_snapshot = null
# #174: Consume examine result — show overlay when server sends character-filtered observation.
func consume_examine_result() -> void:
if GameState.current_examine_result == null or not examine_display:
+6
View File
@@ -213,6 +213,12 @@ static func apply(snapshot: Dictionary) -> void:
else:
GameState.settings_response = null
# v21: economy_snapshot (#824, D-181)
if snapshot.has("economy_snapshot") and snapshot.economy_snapshot is Dictionary:
GameState.economy_snapshot = snapshot.economy_snapshot
else:
GameState.economy_snapshot = null
# #718: character_visual_descriptor — restored from server snapshot on save/load.
if (
snapshot.has("character_visual_descriptor")
+176
View File
@@ -225,10 +225,178 @@ func _dispatch(line: String) -> void:
_send_debug("ListPopulation")
"status":
_send_debug("GetContaminationStatus")
"econ":
_dispatch_econ(parts)
_:
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
# #825: Economics debug console commands (D-178, D-180, D-181).
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
# ship with #823. Until then, _send_debug will transmit the payload but the server will
# respond with an "unknown command" error. Wire format is ready; server handler is not.
func _dispatch_econ(parts: Array) -> void:
if parts.size() < 2:
_append_text(
(
"usage:\n"
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
+ " econ inspect <system_id>"
),
ERROR_COLOR,
)
return
var sub := parts[1].to_lower()
match sub:
"inject":
_econ_inject(parts)
"param":
_econ_param(parts)
"inspect":
_econ_inspect(parts)
_:
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
# Minimal form: econ inject Sol shock 0.5
# Full form: econ inject Sol fusion_fuel boost 1.2 100
func _econ_inject(parts: Array) -> void:
# parts[0]="econ", parts[1]="inject", rest is args
var args := parts.slice(2)
if args.size() < 3:
_append_text(
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
ERROR_COLOR,
)
return
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
var system_id: String = args[0]
var commodity_id: String = ""
var effect: String = ""
var magnitude_str: String = ""
var duration_ticks: int = 0
var ticks_str := ""
if args[1].to_lower() in ["shock", "boost"]:
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
effect = args[1].to_lower()
magnitude_str = args[2]
if args.size() >= 4:
ticks_str = args[3]
elif args.size() == 3:
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
_append_text(
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
)
return
else:
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
commodity_id = args[1]
if args.size() < 4:
_append_text(
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
ERROR_COLOR,
)
return
effect = args[2].to_lower()
if not effect in ["shock", "boost"]:
_append_text(
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
)
return
magnitude_str = args[3]
if args.size() >= 5:
ticks_str = args[4]
if not ticks_str.is_empty():
if not ticks_str.is_valid_int():
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
return
duration_ticks = int(ticks_str)
if not magnitude_str.is_valid_float():
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
return
var magnitude: float = float(magnitude_str)
var payload := {
"system_id": system_id,
"effect": effect,
"magnitude": magnitude,
}
if not commodity_id.is_empty():
payload["commodity_id"] = commodity_id
if duration_ticks > 0:
payload["duration_ticks"] = duration_ticks
_append_text(
(
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
% [
effect,
system_id,
" / " + commodity_id if not commodity_id.is_empty() else "",
magnitude,
duration_ticks
]
),
TEXT_COLOR,
)
_send_debug({"InjectEconEvent": payload})
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
func _econ_param(parts: Array) -> void:
var args := parts.slice(2)
if args.size() < 2:
_append_text(
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
ERROR_COLOR,
)
return
var param_name: String = args[0].to_lower()
if not param_name in ["alpha", "beta", "friction"]:
_append_text(
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
)
return
if not args[1].is_valid_float():
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
return
var value: float = float(args[1])
var payload := {"param": param_name, "value": value}
if args.size() >= 3:
payload["system_a"] = args[2]
if args.size() >= 4:
payload["system_b"] = args[3]
var scope := "global"
if payload.has("system_a") and payload.has("system_b"):
scope = "%s ↔ %s" % [payload["system_a"], payload["system_b"]]
elif payload.has("system_a"):
scope = payload["system_a"]
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
_send_debug({"SetEconParam": payload})
# econ inspect <system_id> — request all 7 D-181 signals for a system
func _econ_inspect(parts: Array) -> void:
if parts.size() < 3:
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
return
var system_id: String = parts[2]
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
_send_debug({"GetEconState": system_id})
func _send_debug(kind: Variant) -> void:
var err := (
SimBridge
@@ -284,6 +452,14 @@ func _print_help() -> void:
+ " triangles — list all triangles\n"
+ " pop — list active NPCs\n"
+ " status — contamination status\n"
+ "\n"
+ "Economics (D-178/D-180/D-181):\n"
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
+ " — fire an EconEvent at a system\n"
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
+ " — set tâtonnement parameter\n"
+ " econ inspect <sys> — show all 7 signals for a system\n"
+ "\n"
+ " help — this list"
),
TEXT_COLOR
+7 -1
View File
@@ -1,7 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
[ext_resource type="PackedScene" path="res://ui/implant/economics_panel.tscn" id="3_econ"]
[node name="HUD" type="Control"]
layout_mode = 3
@@ -19,3 +20,8 @@ script = ExtResource("1_hud")
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
visible = false
; #824: Economics Monitor — implant/economics INSERT panel. Toggled via E key from main.gd.
; Composes ImplantPanel from the D-169 component library. Placeholder data until #822 ships.
[node name="EconomicsPanel" parent="." instance=ExtResource("3_econ")]
visible = false
+315
View File
@@ -0,0 +1,315 @@
class_name EconomicsPanel
extends Control
## Economics Monitor — implant insert panel (#824, D-170, D-181).
##
## Displays price data and GDP for a selected system. Receives economy_snapshot
## from the server via snapshot_handler → GameState → snapshot_consumers pipeline.
##
## Data architecture:
## - Ring buffer: last 20 ticks of economy data per system (for trend display)
## - 7 D-181 signals per system: price_current, price_trend, trade_flow_volume,
## corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio
## - Signals 1-2 (price_current, price_trend) are Phase 2 deliverables
## - Signals 3-7 are parsed and stored but not yet displayed (Phase 3)
##
## Visual layer: ImplantPanel composition built in _ready() from component library (D-169).
## System selector uses LEFT/RIGHT arrow keys to cycle through all 301 systems.
## Placeholder commodity prices shown until #822 ships.
## Emitted when new economy data arrives for the selected system.
signal economy_data_updated(system_id: String, data: Dictionary)
const APP_PATH := "implant/economics"
const RING_BUFFER_SIZE: int = 20
const STAR_MAP_DATA := "res://data/star_map_data.json"
const PANEL_WIDTH: float = 340.0
const PANEL_MARGIN: float = 16.0
# Placeholder commodity rows shown until server ships EconomySnapshot (#822).
# Commodity IDs match D-184 catalog.
const PLACEHOLDER_COMMODITIES: Array[Dictionary] = [
{"id": "fusion_fuel", "name": "FUSION FUEL", "price": 142, "trend": 1},
{"id": "basic_goods", "name": "BASIC GOODS", "price": 58, "trend": 0},
{"id": "machinery", "name": "MACHINERY", "price": 890, "trend": -1},
{"id": "organics", "name": "ORGANICS", "price": 34, "trend": 1},
{"id": "lattice_comp", "name": "LATTICE COMP.", "price": 2240, "trend": 0},
{"id": "pharmaceuticals", "name": "PHARMA", "price": 312, "trend": -1},
]
## Currently selected system for detailed display. Empty = no selection.
var selected_system: String = ""
## Ring buffer: system_id → Array[Dictionary] (most recent last, max RING_BUFFER_SIZE).
## Each entry is one tick's worth of D-181 signals for that system.
var _history: Dictionary = {}
var _insert_active: bool = true
# Visual panel state (D-169 component library)
var _panel: ImplantPanel = null # root container
var _implant_theme: ImplantTheme = null
var _header: ImplantHeader = null # kept for set_content() on system change
var _nav_row: ImplantDataRow = null # system selector nav hint
var _gdp_row: ImplantDataRow = null # GDP value row
var _commodity_rows: Array = [] # ImplantDataRow × 6, updated without full rebuild
var _placeholder_notice: ImplantTextBlock = null # hidden once live data arrives
# System list for the selector (populated from STAR_MAP_DATA)
var _systems: Array = [] # Array[Dictionary], sorted by proper_name
var _selected_idx: int = 0 # index into _systems
func _ready() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = Control.GROW_DIRECTION_BOTH
grow_vertical = Control.GROW_DIRECTION_BOTH
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
# D-170: Register with HUD layer groups
HudGroups.register(self, APP_PATH)
HudGroups.app_changed.connect(_on_app_changed)
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
_load_system_list()
_build_panel()
economy_data_updated.connect(_on_economy_data_updated)
## Called from SnapshotConsumers when economy_snapshot arrives in GameState.
## data: Dictionary keyed by system_id → signal payload (D-181).
func receive_economy_data(data: Dictionary) -> void:
for system_id: String in data:
var signals: Variant = data[system_id]
if not signals is Dictionary:
continue
if not _history.has(system_id):
_history[system_id] = []
var buf: Array = _history[system_id]
buf.append(signals)
if buf.size() > RING_BUFFER_SIZE:
_history[system_id] = buf.slice(buf.size() - RING_BUFFER_SIZE)
# Notify listeners if the selected system received new data
if not selected_system.is_empty() and data.has(selected_system):
economy_data_updated.emit(selected_system, data[selected_system])
## Select a system for detailed display. Emits economy_data_updated if history exists.
func select_system(system_id: String) -> void:
selected_system = system_id
if not selected_system.is_empty() and _history.has(selected_system):
var buf: Array = _history[selected_system]
if buf.size() > 0:
economy_data_updated.emit(selected_system, buf[buf.size() - 1])
## Get the full ring buffer for a system (for chart/sparkline rendering).
## Returns empty array if no history exists.
func get_history(system_id: String) -> Array:
return _history.get(system_id, [])
## Get the latest tick's signals for a system, or empty dict.
func get_latest(system_id: String) -> Dictionary:
var buf: Array = _history.get(system_id, [])
if buf.size() > 0:
return buf[buf.size() - 1]
return {}
## Get all system IDs that have received at least one tick of data.
func get_known_systems() -> Array:
return _history.keys()
## Toggle via HUD layer system (D-170). INSERT mode — shares screen with gameplay.
func toggle_visible() -> void:
HudGroups.toggle_app(APP_PATH, HudGroups.Mode.INSERT)
## Called from main.gd when insert state changes (D-170).
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and HudGroups.is_app_active(APP_PATH):
HudGroups.close_app()
## Respond to app layer changes (D-170).
func _on_app_changed(app_path: String, mode: int) -> void:
if app_path != APP_PATH:
return
if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT:
visible = true
else:
visible = false
# =============================================================================
# System list — populated from star_map_data.json
# =============================================================================
func _load_system_list() -> void:
if not FileAccess.file_exists(STAR_MAP_DATA):
push_warning("EconomicsPanel: %s not found" % STAR_MAP_DATA)
return
var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ)
if file == null:
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
return
for node: Dictionary in parsed.get("nodes", []):
var sid: String = node.get("system_id", "")
if not sid.is_empty():
_systems.append(node)
_systems.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
var na: String = a.get("proper_name", a.get("system_id", ""))
var nb: String = b.get("proper_name", b.get("system_id", ""))
return na < nb
)
if not _systems.is_empty():
selected_system = _systems[0].get("system_id", "")
# =============================================================================
# Visual panel — D-169 ImplantPanel composition
# =============================================================================
func _build_panel() -> void:
_panel = ImplantPanel.new()
_panel.name = "EconPanel"
_panel.theme_resource = _implant_theme
_panel.custom_minimum_size.x = PANEL_WIDTH
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
add_child(_panel)
_rebuild_panel()
## Full rebuild of panel components. Called on system change and initial build.
func _rebuild_panel() -> void:
if not _panel:
return
_panel.clear()
_commodity_rows.clear()
var node: Dictionary = _current_node()
var sys_name: String = node.get("proper_name", node.get("system_id", "—"))
var sys_id: String = node.get("system_id", "")
var total: int = _systems.size()
# ── Header ────────────────────────────────────────────────────────────────
_header = ImplantHeader.new("ECONOMICS MONITOR", sys_name)
_panel.add_component(_header)
_panel.add_component(ImplantSeparator.new())
# ── System selector nav ────────────────────────────────────────────────────
var nav_hint := "◄ ► · %s [%d / %d]" % [sys_id, _selected_idx + 1, total]
_nav_row = ImplantDataRow.new(nav_hint)
_panel.add_component(_nav_row)
_panel.add_component(ImplantSeparator.new())
# ── GDP strip ─────────────────────────────────────────────────────────────
var gdp_str: String = node.get("gdp", "—")
_gdp_row = ImplantDataRow.new("gdp " + gdp_str)
_panel.add_component(_gdp_row)
_panel.add_component(ImplantSeparator.new())
# ── Price table ───────────────────────────────────────────────────────────
_panel.add_component(ImplantTextBlock.new("MARKET PRICES"))
var latest: Dictionary = get_latest(sys_id)
var commodity_signals: Array = latest.get("price_current", [])
for c: Dictionary in PLACEHOLDER_COMMODITIES:
var cid: String = c.get("id", "")
var price: int = c.get("price", 0)
var trend: int = c.get("trend", 0)
# Overlay live data when available (D-181 signal 1-2)
for sig: Dictionary in commodity_signals:
if sig.get("commodity_id", "") == cid:
price = int(sig.get("price_current", price))
trend = int(sig.get("price_trend", trend))
break
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
var row := ImplantDataRow.new(row_text)
_panel.add_component(row)
_commodity_rows.append(row)
# ── Placeholder notice ────────────────────────────────────────────────────
_panel.add_component(ImplantSeparator.new())
var notice_text: String = (
"[LIVE MARKET — #822 PENDING]" if _history.is_empty() else "LIVE DATA ACTIVE"
)
_placeholder_notice = ImplantTextBlock.new(notice_text)
_panel.add_component(_placeholder_notice)
_panel.add_component(ImplantSeparator.new())
_panel.add_component(ImplantTextBlock.new("[ ] select system · N close"))
func _current_node() -> Dictionary:
if _systems.is_empty():
return {}
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
return _systems[_selected_idx]
func _trend_glyph(trend: int) -> String:
if trend > 0:
return "▲"
if trend < 0:
return "▼"
return "—"
## Respond to economy_data_updated signal — refresh the price table in-place.
func _on_economy_data_updated(system_id: String, data: Dictionary) -> void:
if not _panel or _commodity_rows.is_empty():
return
if system_id != selected_system:
return
var commodity_signals: Array = data.get("price_current", [])
for i: int in range(PLACEHOLDER_COMMODITIES.size()):
if i >= _commodity_rows.size():
break
var c: Dictionary = PLACEHOLDER_COMMODITIES[i]
var cid: String = c.get("id", "")
var price: int = c.get("price", 0)
var trend: int = c.get("trend", 0)
for sig: Dictionary in commodity_signals:
if sig.get("commodity_id", "") == cid:
price = int(sig.get("price_current", price))
trend = int(sig.get("price_trend", trend))
break
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
_commodity_rows[i].text = row_text
if _placeholder_notice and not _history.is_empty():
_placeholder_notice.text = "LIVE DATA ACTIVE"
## Cycle the system selector by delta steps (+1 or -1).
## Called from main.gd _unhandled_key_input — [ and ] keys when panel is active.
func navigate(delta: int) -> void:
if _systems.is_empty():
return
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
selected_system = _systems[_selected_idx].get("system_id", "")
_rebuild_panel()
+18
View File
@@ -0,0 +1,18 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/implant/economics_panel.gd" id="1_econ"]
; #824: Economics Monitor insert panel — price data and GDP for selected system.
; Composed from ImplantPanel component library (D-169). Registered under implant/economics (D-170).
; Toggle with E key in implant mode. Data flows from EconomySnapshot via snapshot_consumers.
; Placeholder commodity prices shown until server ticket #822 ships.
[node name="EconomicsPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("1_econ")
+6 -4
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
@@ -507,10 +506,13 @@ func _rebuild_info_panel() -> void:
# Population + GDP
var population: String = node.get("population", "")
if not population.is_empty():
var gdp: String = node.get("gdp", "")
if not population.is_empty() or not gdp.is_empty():
_info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
_info_panel.add_component(ImplantDataRow.new("pop " + population))
_info_panel.add_component(ImplantDataRow.new("GDP —"))
if not population.is_empty():
_info_panel.add_component(ImplantDataRow.new("pop " + population))
var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—")
_info_panel.add_component(ImplantDataRow.new(gdp_label))
# ── GTTR excerpt ─────────────────────────────────────────────────────────
var gttr: String = node.get("gttr_excerpt", "")
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
# Sprint 34: Pulse — Client Tasks
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
**Branch:** `sprint-34/client`
**Agents:** Stig (UI), Tyre (architecture)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #785 | Add system population and GDP to star map info panel | — |
| #824 | Economics insert panel — price history charts and GDP display | #822 (server) |
| #825 | Economics debug console commands — event triggers and param sliders | #823 (server) |
## Key Decisions
- `decisions/economics.md` — D-181 (7-signal vocabulary — signals 1-2 are Phase 2: price_current, price_trend), D-180 (event port — the commands #825 fires)
- `decisions/architecture.md` — D-169 (implant UI component library — compose from client/ui/implant/), D-170 (HUD visibility groups — economics panel lives in INSERT mode), D-020 (IPC — EconomySnapshot arrives in ObserverSnapshot)
## Notes
### #785 — System population and GDP to star map info panel
When a system is selected in the star map (`client/ui/star_map.gd`, `client/ui/star_map.tscn`), the popup built with ImplantPanel components shows: name, star type, hop distance, corridor, GTTR excerpt, bodies, adjacents. Add two new `ImplantDataRow` entries: `POPULATION` and `GDP`. Data is already in `res://data/star_map_data.json` (regenerated by `tooling/generate-star-map-data.py` from `systems.db`). Check whether population and GDP fields are present in the JSON; if not, update the generation script as part of this ticket. This is standalone — no server dependency. Good warmup ticket; complete it first.
### #824 — Economics insert panel
New implant panel: **Economics Monitor**. Lives in INSERT mode (D-170), accessible via implant navigation alongside the star map.
Scene: `client/ui/implant/economics_panel.tscn` + `client/ui/implant/economics_panel.gd`
Compose strictly from the existing component library (`client/ui/implant/`):
- `ImplantPanel` — root container
- `ImplantHeader` — "ECONOMICS MONITOR" title + selected system subtitle
- `ImplantSeparator` — section dividers
- `ImplantDataRow` — key/value rows for price and GDP data
- `ImplantTextBlock` — top commodity summary text
Layout (three sections):
1. **System selector** — searchable/scrollable list of systems (can reuse star map system data). Selecting a system triggers an `EconStateQuery` PlayerAction to the server.
2. **Price table** — top 6 commodities for selected system, each as an `ImplantDataRow` with `price_current` and a directional trend indicator (▲ / ▼ / —) derived from `price_trend`.
3. **GDP strip** — total economic activity for the system displayed as a single row. Update each time a new `EconomySnapshot` arrives.
Data flow: `snapshot_handler.gd` receives ObserverSnapshot v21. When `economy_snapshot` is present, forward to `economics_panel.gd` via a signal or direct call. The panel caches the last 20 ticks of price data per system for trend display (ring buffer in GDScript Dictionary).
Do NOT draw custom canvas sparklines unless time allows — `ImplantDataRow` with a trend arrow is the MVP. The price chart can be a follow-on.
Register the panel in `client/scripts/autoloads/hud_groups.gd` under path `implant/economics`. Add a keyboard shortcut (e.g. `E` in implant mode) and an entry in the implant navigation menu.
Blocked by #822 (server must expose EconomySnapshot before the panel has real data). Build the panel with placeholder data first; wire live data once #822 ships.
### #825 — Economics debug console commands
The debug console exists at `client/ui/debug_console.gd` + `client/ui/debug_console.tscn`. The console already dispatches `DebugCommandKind` variants via `PlayerAction::DebugCommand` through the IPC bridge.
Add three new command parsers in `_parse_command()` / `_dispatch_command()`:
```
econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
→ InjectEconEvent { system_id, commodity_id, effect, magnitude, duration_ticks }
econ param <alpha|beta|friction> <value> [system_a] [system_b]
→ SetEconParam { param, value }
econ inspect <system_id>
→ GetEconState { system_id }
```
`econ inspect` returns all 7 D-181 signals for the system; display in console output log as a multi-line block. `econ inject` and `econ param` print a confirmation + the server's `DebugResponsePayload.text`.
Update `_print_help()` to include the `econ` command family. Blocked by #823 (server must handle the variants before the client can send them meaningfully, though client-side parsing can be built in parallel).
## Dependency Chain
```
#785 (star map GDP) — standalone, start here
#822 (server IPC, Sprint 34/server) → #824 (economics insert panel)
#823 (server debug handler, Sprint 34/server) → #825 (debug console commands)
#824 and #825 are parallel after their respective server blockers clear.
```
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(ui): economics insert panel and debug console econ commands" \
--description "Sprint 34 client work" \
--base main --head sprint-34/client
```
+61
View File
@@ -0,0 +1,61 @@
# Sprint 34: Pulse — Copy Tasks
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
**Branch:** `sprint-34/copy`
**Agents:** Mellanie (author), Paula (narrative)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #814 | Rail infrastructure corporation gap | — |
| #695 | Author overheard conversations for remaining 24 zone types | — |
## Key Decisions
- `decisions/economics.md` — D-182 (TOML source of truth — all economics content lives in `wiki/economics/`), D-175 (corporation taxonomy — Tier 1/2/3 structure)
- `decisions/content.md` — D-142 (zone-type template architecture — 31 zone types defined), D-139 (composable behavior primitives — overheard conversations are Layer 2 cultural flavor)
## Notes
### #814 — Rail infrastructure corporation gap
**Context:** The corporation validation pipeline (`tooling/economy-db/import_economics.py`) flagged a gap: no existing wiki corporation produces the `rail_infrastructure` commodity. This is a lore-world gap as much as a data gap — rail is the primary intra-continental transit system on inhabited worlds (see `decisions/architecture.md` D-093 for Sova Transit context, `wiki/economics/production_chains.toml` for chain definitions).
**Deliverable:** Either (a) assign `rail_infrastructure` production to an existing Tier 1 or Tier 2 corporation (MVG — Marvian Gravity Works — is the most plausible candidate given its Tier 1 infrastructure mandate) or (b) create a new corporation if no existing corp fits. Update:
- `wiki/economics/corporations.toml` — add production entry
- Corresponding wiki corporation page (if new corp: `wiki/corporations/<name>.md`)
- Verify `make economy-db` passes after the change
Do not assign to a Tier 3 regional corp — rail infrastructure is a systemic commodity that should have Tier 1 or Tier 2 backing.
### #695 — Overheard conversations for remaining 24 zone types
**Context:** `server/content/global/overheard.ron` currently covers 5 of 29 zone types (~17% coverage). The remaining 24 types need 2-4 role-pair conversations each. These are the passive ambient dialogue lines that play when NPCs are overheard by the player without direct engagement (D-078).
**Format:** Each conversation entry in `overheard.ron` follows the existing pattern — two role slugs, a setting line (terse, 1 sentence describing where/when), and 3-5 lines of dialogue. Lines should feel naturalistic for the zone type; the NPC pair should be plausible co-workers or passers-by given the zone's economic activity.
**Zone types to cover:** Check `server/content/global/zone-types/` for the full list. Currently covered: the 5 types already in `overheard.ron` (verify by reading the file). Write 2-4 conversations per remaining zone type. Prioritize the zone types most likely to be visited first in a playthrough: `residential_dense`, `commercial_retail`, `transit_hub`, `office_district`, `industrial_light`.
**Lore anchors:** Use the wiki cultural pages (`wiki/cultures/`) for voice and slang. Zone types that map to specific planetary environments (agricultural, wilderness) should reflect the relevant culture. Avoid generic SF clichés — these lines should feel like they belong in the Reach.
**Volume:** 24 zone types × 3 conversations average × 4 lines each = ~288 lines total. Work zone-type by zone-type; commit partial coverage. Do not block on completing all 24 before committing.
## Dependency Chain
```
#814 (rail corp gap) — standalone
#695 (overheard conversations) — standalone, parallel
```
Both tickets are independent and can run in parallel.
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "content(economics): rail corp gap and overheard conversation coverage" \
--description "Sprint 34 copy work" \
--base main --head sprint-34/copy
```
+115
View File
@@ -0,0 +1,115 @@
# Sprint 34: Pulse — Joint Briefing
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
---
## Pre-Sprint: Decisions and Schema
No blocking pre-sprint decisions required. All economic architecture decisions (D-178 through D-188) are confirmed. #810 (event port) is the technical gate for the full chain — it must be the first ticket the server team starts.
| Item | Owner | Status |
|------|-------|--------|
| D-178: Economic Model Architecture | decisions/economics.md | Confirmed |
| D-179: Stability Acceptance Criteria | decisions/economics.md | Confirmed |
| D-180: Event Input Port | decisions/economics.md | Confirmed |
| D-181: Signal Vocabulary | decisions/economics.md | Confirmed |
| ObserverSnapshot v21 schema | Server → client | New this sprint (#822) |
---
## Sprint Ticket Map
### Server (sprint-34/server)
```
#810 Event port implementation
→ #821 Integrate econ-sim into server tick loop
→ #822 Expose economy state over IPC (ObserverSnapshot v21)
→ #823 Economics debug command handler
```
### Client (sprint-34/client)
```
#785 Star map: system population + GDP (standalone)
#822 (server, blocker) → #824 Economics insert panel
#823 (server, blocker) → #825 Debug console econ commands
```
### Copy (sprint-34/copy)
```
#814 Rail infrastructure corporation gap (standalone)
#695 Overheard conversations — 24 zone types (standalone, parallel)
```
### Planning (sprint-34/planning)
```
#811 Brand layer design (early sprint)
#748 Phase 3 breakdown workshop (after server tickets in_progress)
```
---
## Cross-Team Integration Points
**ObserverSnapshot v21 (server → client)**
- Server: `server/src/bridge/types.rs` — add `EconomySnapshot` struct, bump `PROTOCOL_VERSION` to 21
- Client: `client/scripts/snapshot_handler.gd` — parse `economy_snapshot` field, route to economics panel
- Coordination: Server team defines the struct; client team consumes it. Server team ships #822 first; client team builds #824 with placeholder data in the meantime.
**Debug command flow (both teams)**
- Server: `server/src/bridge/types.rs` — add `InjectEconEvent`, `SetEconParam`, `GetEconState` to `DebugCommandKind`
- Client: `client/ui/debug_console.gd` — add `econ inject`, `econ param`, `econ inspect` command parsers
- Coordination: Server team ships #823 before client team wires #825. Client team can build command parsing and help text independently; just gate the send path on #823 being merged.
**Star map GDP (#785)**
- Client-only ticket. Check whether `res://data/star_map_data.json` already includes `population` and `gdp` fields. If not, update `tooling/generate-star-map-data.py` to include them from `server/data/systems.db`. This is a self-contained warmup — complete before #824.
---
## Cascade Enforcement
**No Phase 4 tickets.** This sprint closes Phase 2 and initiates Phase 3 planning via #748 and #811. The following are explicitly out of scope and must not be started, designed, or discussed:
- Character creation (#618, #619, #694, #606)
- Tycoon starting states (#615)
- Bookmark system (#614)
- NPC personality surface area (#621)
- Apartment generator (#617, #681)
- Any ticket under Phase 4 epic #749
Phase 4 cannot start until Phase 3 delivers (Atlas of the Reach). Phase 3 planning workshop (#748) runs this sprint — but Phase 3 implementation tickets do not start until Sprint 35+.
---
## Sprint Completion Proof
The sprint is done when a developer can do all three of the following in the running game:
1. **Open the implant economics panel** — select any system, see live price data (price_current and price_trend for at least 6 commodities) updating in real time as the economy runs.
2. **Trigger a supply shock from the debug console** — type `econ inject <system_id> shock 0.5 200`, observe the price_current values shift in the economics panel within a few ticks, then recover toward equilibrium over the next 200 ticks.
3. **Mutate α from the debug console** — type `econ param alpha 0.01`, observe slower price adjustment in the panel; type `econ param alpha 0.06`, observe faster adjustment.
None of the above require a character. The economics panel and debug console can be exercised from the main game loop without entering the simulation world — they operate via the IPC bridge on whatever player session is active.
---
## Test Plan
**Phase alignment:** Sprint 34 is Phase 2 delivery infrastructure. Test focus is economic state correctness over IPC, not simulation model correctness (that was Sprint 33 / D-179 stability tests).
| Test | Tier | Owner |
|------|------|-------|
| ObserverSnapshot v21 roundtrip — serialize/deserialize EconomySnapshot | Tier 1 (fixture) | Server |
| EconStateQuery → economy_snapshot present in next snapshot | Tier 2 (bridge) | Server |
| `econ inject` → DebugResponsePayload.success true + price shift observable | Tier 2 (bridge) | Server |
| Economics insert panel renders with fixture EconomySnapshot | Tier 3 (client live) | Client |
| Debug console parses `econ inject` without error | Tier 3 (client live) | Client |
| Star map popup shows population + GDP fields | Tier 3 (client live) | Client |
---
## Open Questions
None blocking implementation. One design question in-flight:
- **Q: Brand layer architecture** (#811 planning) — not blocking Sprint 34 implementation work. Resolved by planning team this sprint; produces Phase 3 tickets for Sprint 35.
+103
View File
@@ -0,0 +1,103 @@
# Sprint 34: Pulse — Planning Tasks
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
**Branch:** `sprint-34/planning`
**Agents:** Gestalt (systems), Burnelli-Sheldon (economics), Tyre (technical), Miri (worldbuilding), Qatux (documenter), SI (project manager)
## Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #748 | Phase 3: Planetary/moon maps and station layouts — Atlas of the Reach | #747 (in progress → closes this sprint) |
| #811 | Brand layer design | — |
## Context to Read Before Discussion
For **#811 (Brand layer design):**
- `decisions/economics.md` — D-185 (Brands Are Not Commodities), D-184 (Commodity Catalog — what exists), D-173 (Commodity Taxonomy — three-tier structure)
- `decisions/scope.md` — D-131 (broad economic verb vocabulary), D-118 (small business owner starting state — brands are the Phase 3 player layer)
- `wiki/economics/commodities.toml`, `wiki/economics/production_chains.toml`
For **#748 (Phase 3 breakdown):**
- `CLAUDE.md` — Development cascade table (Phase 3 = Planetary/moon maps, deliverable = Atlas of the Reach)
- `decisions/architecture.md` — D-093 (Sova Transit District spatial layout), D-094 (district spatial hierarchy), D-095 (Horizon stations and gate infrastructure)
- Sprint 33 deliverable: `server/data/systems.db` populated with 300+ systems, gate links, currency zones
- `docs/atlas/` — existing atlas content
---
## #811 — Brand Layer Design
**Type:** Planning discussion — produces a D-record in `decisions/economics.md`
**What this is:** The brand/luxury goods system sits on top of the commodity layer (D-185 confirms brands are NOT commodities). Brands consume commodities as inputs. Brand pricing is driven by cultural/emotional/want mechanics, not tâtonnement. This design work answers the Phase 3 question: how does a player engage with the economy as a participant (producer/trader/brand-builder) rather than an observer?
**Discussion rounds:**
**Round 1 — Inventory (what exists, what is missing)**
- What is the full design space of "brand" in the Reach? (Gestalt, Miri)
- What D-records already constrain brand design? (Tyre reads economics.md, scope.md)
- What is the player's economic verb set when brands exist? (Burnelli-Sheldon, Gestalt)
**Round 2 — Proposals**
- Brand representation: is a brand a DB entity, a modifier on a commodity, or a separate production chain layer? (Tyre, Burnelli-Sheldon)
- Cultural pricing model: how does a brand's cultural origin affect demand across currency zones? (Miri, Gestalt)
- Player access: what verbs does a player have toward an existing brand vs. founding one? (Gestalt)
**Round 3 — Convergence**
- Draft one D-record covering: brand representation in the data model, cultural demand pricing, player access verbs, and the boundary with Phase 2 commodity tâtonnement
- SI creates follow-up implementation tickets for Phase 3 sprint
**Output:** D-NNN in `decisions/economics.md` (claim ID via `tooling/db/decision claim D economics "Brand layer architecture"`). Qatux files the record. SI creates 2-4 Phase 3 implementation tickets from the decision.
**CONSTRAINT:** This design session covers brand layer architecture only. No character creation, no tycoon states, no apartment generators. Phase 4 work is out of scope until Phase 3 delivers.
---
## #748 — Phase 3 Planetary Maps Breakdown Workshop
**Type:** Planning discussion — produces a sprint-ready ticket breakdown for Phase 3
**What this is:** Phase 3 deliverable is the Atlas of the Reach (implant app) — region-level maps at hundreds-of-km scale. Cities, rivers, mountains, rail lines, gate/portal locations, road hierarchy, named areas. This workshop answers: what is the minimal scope for a shippable Phase 3, and what tickets does it generate?
**Timing:** This discussion runs AFTER the server team confirms Phase 2 is closing (economics in-game, IPC bridge live). Do not start this discussion until Sprint 34 server tickets are at least in_progress.
**Discussion rounds:**
**Round 1 — Inventory**
- What does Phase 3 require that does not exist? Read `docs/atlas/`, existing world data in `server/data/systems.db`. (Miri, Tyre)
- What systems from Phase 2 does Phase 3 build on (gate network, system data, planet_class)? (Gestalt, Tyre)
- What is the rendering target? (Implant app panel — same component library as economics panel?) (Tyre)
**Round 2 — Scope definition**
- Define the MVP Atlas: which systems get maps first? (Miri — Sova/Krenn as canonical first-system per D-036)
- Data authoring pipeline: how are planetary maps authored? Hand-drawn overlays on procedural heightmaps? Pure procedural? (Miri, Gestalt)
- Implant app design: what does the Atlas panel look like? Click-through from the star map? (Tyre)
**Round 3 — Ticket breakdown**
- Break Phase 3 into 4-8 implementation tickets across server, client, copy, visual teams
- Assign team and priority to each
- SI creates the tickets and blocks them appropriately under #748
**Output:** 4-8 new tickets (server + client + copy + visual) with team assignments, priorities, and explicit dependencies. SI creates them immediately at round end.
---
## Dependency Chain
```
#811 (brand layer design) — run early in sprint, unblocks Phase 3 planning
#748 (Phase 3 breakdown) — run after server Sprint 34 tickets are in_progress
```
## PR Workflow
Planning branch produces decisions and ticket updates only — no code. Commit decisions and close tickets:
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "planning(economics): brand layer design and Phase 3 breakdown" \
--description "Sprint 34 planning work" \
--base main --head sprint-34/planning
```
+123
View File
@@ -0,0 +1,123 @@
# Sprint 34: Pulse — Server Tasks
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
**Branch:** `sprint-34/server`
**Agents:** Dudley (simulation), Tyre (architecture)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #810 | Event input port implementation | #809 (done) |
| #821 | Integrate econ-sim into game server tick loop | #810 |
| #822 | Expose economy state over IPC bridge to client | #821 |
| #823 | Economics debug command handler — event injection and parameter mutation | #821 |
## Key Decisions
- `decisions/economics.md` — D-178 (model architecture — Leontief + tâtonnement + agents), D-179 (stability criteria), D-180 (event input port — EconEvent struct and visibility modes), D-181 (7-signal vocabulary per node), D-183 (iterative dev cycle)
- `decisions/architecture.md` — D-020 (IPC architecture — ObserverSnapshot + PlayerAction), D-031 (tick-to-time mapping — 10 ticks = 1 game-minute)
## Notes
### #810 — Event input port implementation
The EconEvent struct (D-180) must be added to `tooling/econ-sim/src/model.rs` or a new `events.rs` module. The port is the typed interface through which all external disruptions enter the simulation. An event carries:
```
EconEvent {
target: Node | NodeSet | Corridor | TradeRoute | Currency | Commodity,
effect: ProductivityMultiplier | CapacityMultiplier | DemandShock | ExchangeShock,
duration: ticks,
visibility: Global | Proximate(hops) | Disclosed(specific_nodes) | Hidden,
}
```
Visibility modes are defined in D-180. For this sprint, only `Global` and `Proximate` need to be exercised — `Hidden` is Phase 3 territory (requires the player inspect verb). The port must accept events from: (a) the server tick loop (#821), and (b) debug commands (#823). Test: inject a supply shock, verify cascade propagates and prices recover within 200 ticks per D-179 Test 3.
### #821 — Integrate econ-sim into game server tick loop
The econ-sim is currently a standalone CLI binary at `tooling/econ-sim/`. This ticket makes it run inside the server process. Approach:
1. Extract the simulation logic from `tooling/econ-sim/src/main.rs` into a reusable library crate (e.g. `tooling/econ-sim/src/lib.rs` or a new `server/src/economy/` module — Tyre to decide the crate boundary).
2. Add a `bevy_ecs` `System` that advances the economy N ticks per game tick (rate TBD — likely 1 economy tick per 10 game ticks given D-031 tick-to-time mapping).
3. Store the current economy state as a `Resource` in bevy_ecs so downstream systems (#822, #823) can query it.
4. Economy state must include all 7 D-181 signals per active node so the bridge can later serialize the relevant subset.
Key files: `server/src/simulation/ticker.rs` (where per-tick systems run), `tooling/econ-sim/src/model.rs` (simulation state), `tooling/econ-sim/src/trade.rs` (tâtonnement step). The DB at `server/data/systems.db` is already populated from Sprint 33.
Do NOT load the econ DB on every tick — load once at server startup into the bevy_ecs Resource.
### #822 — Expose economy state over IPC bridge to client
Extend `ObserverSnapshot` to version 21 with an `economy_snapshot` field:
```rust
#[serde(default)]
pub economy_snapshot: Option<EconomySnapshot>,
```
`EconomySnapshot` carries per-system data for the client's economics panel (#824). Phase 2 deliverable is D-181 signals 1–2 only (price_current, price_trend). Struct sketch:
```rust
pub struct EconomySnapshot {
pub tick: u64,
pub nodes: Vec<EconNodeSnapshot>,
}
pub struct EconNodeSnapshot {
pub system_id: u32,
pub commodity_id: u32,
pub price_current: f64,
pub price_trend: f64, // delta over last N ticks
}
```
Add `EconStateQuery` to the `PlayerAction` enum for on-demand pulls — the client does not need economy data every tick (that would balloon snapshot size). The server responds to `EconStateQuery` by populating `economy_snapshot` on the next snapshot. Without a query, `economy_snapshot` is `None`.
Update `PROTOCOL_VERSION` to 21 in `server/src/bridge/types.rs`.
### #823 — Economics debug command handler
Extend `DebugCommandKind` in `server/src/bridge/types.rs` with three new variants:
```rust
/// Inject an economic event into the running simulation.
InjectEconEvent {
system_id: u32,
commodity_id: Option<u32>, // None = system-wide
effect: EconDebugEffect,
magnitude: f64,
duration_ticks: u32,
},
/// Mutate a tâtonnement parameter at runtime.
SetEconParam {
param: EconParamKind, // Alpha | Beta | CorridorFriction { system_a, system_b }
value: f64,
},
/// Return all 7 D-181 signals for a named system.
GetEconState {
system_id: u32,
},
```
Wire these into the existing debug command dispatch in `server/src/simulation/` (wherever `DebugCommandKind` is matched). Return results via `DebugResponsePayload.text` as a human-readable multi-line string. Blocked by #821 (economy resource must exist to query or mutate).
## Dependency Chain
```
#810 (event port) → #821 (server tick integration) → #822 (IPC exposure)
→ #823 (debug command handler)
```
#822 and #823 are parallel after #821 completes.
## PR Workflow
```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
--title "feat(simulation): economics in-game tick loop and IPC bridge" \
--description "Sprint 34 server work" \
--base main --head sprint-34/server
```
+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",
+2
View File
@@ -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((
+87 -50
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
@@ -106,6 +91,45 @@ def parse_gttr_excerpt(system_id: str) -> str:
return " ".join(paragraph_lines)
GDP_PER_CAPITA: dict = {
5: 75_000,
4: 40_000,
3: 15_000,
2: 5_000,
1: 2_000,
0: 500,
}
def infer_tier_from_population(pop: int) -> int:
"""Infer an economic tier from total population when no explicit tier is set."""
if pop >= 5_000_000_000:
return 5
if pop >= 1_000_000_000:
return 4
if pop >= 200_000_000:
return 3
if pop >= 10_000_000:
return 2
return 1
def compute_gdp(total_pop: int, economic_tier: int | None) -> str:
"""Return a formatted GDP string in Tractus, or empty string if no population."""
if total_pop == 0:
return ""
tier = economic_tier if economic_tier is not None else infer_tier_from_population(total_pop)
per_cap = GDP_PER_CAPITA.get(tier, GDP_PER_CAPITA[1])
value = total_pop * per_cap
if value < 1_000_000_000:
return f"{value / 1_000_000:.1f} MTr"
if value < 1_000_000_000_000:
return f"{value / 1_000_000_000:.1f} BTr"
if value < 1_000_000_000_000_000:
return f"{value / 1_000_000_000_000:.1f} TTr"
return f"{value / 1_000_000_000_000_000:.1f} QTr"
def build_adjacency(edges: list) -> dict:
"""Build a map from system_id to list of adjacent system_ids from edges."""
adj: dict = {}
@@ -134,35 +158,45 @@ def generate() -> dict:
with open(STAR_MAP_PATH) as f:
star_map = json.load(f)
conn = sqlite3.connect(SYSTEMS_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"SELECT system_id, proper_name, geographic_sector, geographic_band "
"FROM star_systems"
)
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
try:
conn = sqlite3.connect(SYSTEMS_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Aggregate body data per system from the bodies table
cur.execute("""
SELECT system_id,
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
FROM bodies
GROUP BY system_id
""")
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
cur.execute(
"SELECT system_id, proper_name, geographic_sector, geographic_band "
"FROM star_systems"
)
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Also sum station populations
cur.execute("""
SELECT system_id,
SUM(COALESCE(population, 0)) AS station_pop
FROM stations
GROUP BY system_id
""")
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
conn.close()
# Aggregate body data per system from the bodies table
cur.execute("""
SELECT system_id,
SUM(CASE WHEN atmosphere IN ('breathable','standard') AND body_type IN ('planet','moon') THEN 1 ELSE 0 END) AS habitable,
SUM(CASE WHEN inhabited = 1 THEN 1 ELSE 0 END) AS inhabited,
SUM(CASE WHEN inhabited = 1 THEN COALESCE(population, 0) ELSE 0 END) AS total_pop
FROM bodies
GROUP BY system_id
""")
body_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Also sum station populations
cur.execute("""
SELECT system_id,
SUM(COALESCE(population, 0)) AS station_pop
FROM stations
GROUP BY system_id
""")
station_stats = {row["system_id"]: dict(row) for row in cur.fetchall()}
# Economic tier for GDP calculation
cur.execute("SELECT system_id, economic_tier FROM system_economy")
econ_tiers = {row["system_id"]: row["economic_tier"] for row in cur.fetchall()}
except sqlite3.Error as e:
print(f"ERROR: systems.db query failed: {e}", file=sys.stderr)
sys.exit(1)
finally:
conn.close()
adjacency = build_adjacency(star_map["edges"])
@@ -204,6 +238,9 @@ def generate() -> dict:
entry["bodies"] = "%d habitable · %d inhabited" % (hab, inh)
entry["population"] = "{:,}".format(total_pop)
gdp_str = compute_gdp(total_pop, econ_tiers.get(sid))
if gdp_str:
entry["gdp"] = gdp_str
if gttr:
entry["gttr_excerpt"] = gttr
if n.get("is_gateway"):