Compare commits
17
Commits
@@ -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
@@ -116,6 +116,12 @@ var ai_enhanced_dialogue_enabled: bool = true
|
||||
# "full" response hydrates ai_enhanced_dialogue_enabled (server is authoritative for persisted state).
|
||||
var settings_response: Variant = null
|
||||
|
||||
# v21 fields (#824, D-181): Economy snapshot from server.
|
||||
# Dictionary keyed by system_id → { price_current, price_trend, trade_flow_volume,
|
||||
# corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio }
|
||||
# Null when no economy data in the current snapshot.
|
||||
var economy_snapshot: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ extends Node
|
||||
## "implant/map/starchart" — star map navigator
|
||||
## "implant/wiki/gttr" — Drifter's Guide reader
|
||||
## "implant/journal" — knowledge journal
|
||||
## "implant/economics" — economics monitor (D-181, #824)
|
||||
##
|
||||
## Usage:
|
||||
## HudGroups.register(self, "implant/map/starchart")
|
||||
|
||||
+15
-1
@@ -9,7 +9,6 @@ var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
|
||||
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@@ -34,6 +33,7 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
@onready var economics_panel = $InsertOverlay/HUD/EconomicsPanel # #824: economics monitor (D-170)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -92,6 +92,7 @@ func _ready() -> void:
|
||||
"interaction_prompt": interaction_prompt,
|
||||
"minimap": minimap,
|
||||
"star_map": star_map,
|
||||
"economics_panel": economics_panel,
|
||||
},
|
||||
_screen_flash
|
||||
)
|
||||
@@ -154,6 +155,7 @@ func _ready() -> void:
|
||||
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
|
||||
_router.register("save_result", _consumers.consume_save_result)
|
||||
_router.register("debug_response", _consumers.consume_debug_response)
|
||||
_router.register("economy_snapshot", _consumers.consume_economy_snapshot)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle
|
||||
if settings_dialog and debug_console:
|
||||
@@ -170,6 +172,18 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.keycode == KEY_M:
|
||||
if star_map:
|
||||
star_map.toggle_visible()
|
||||
elif event is InputEventKey and event.keycode == KEY_N:
|
||||
# #824: N — toggle Economics Monitor implant panel (E is bound to interact)
|
||||
if economics_panel:
|
||||
economics_panel.toggle_visible()
|
||||
elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT:
|
||||
# #824: [ — cycle economics panel system selector backward
|
||||
if economics_panel and HudGroups.is_app_active("implant/economics"):
|
||||
economics_panel.navigate(-1)
|
||||
elif event is InputEventKey and event.keycode == KEY_BRACKETRIGHT:
|
||||
# #824: ] — cycle economics panel system selector forward
|
||||
if economics_panel and HudGroups.is_app_active("implant/economics"):
|
||||
economics_panel.navigate(1)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
@@ -17,6 +17,7 @@ var interaction_list: Node = null
|
||||
var interaction_prompt: Node = null
|
||||
var minimap: Node = null
|
||||
var star_map: Node = null
|
||||
var economics_panel: Node = null # #824: economics monitor (D-181)
|
||||
|
||||
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
|
||||
|
||||
@@ -37,6 +38,7 @@ func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
|
||||
interaction_prompt = refs.get("interaction_prompt")
|
||||
minimap = refs.get("minimap")
|
||||
star_map = refs.get("star_map")
|
||||
economics_panel = refs.get("economics_panel")
|
||||
_screen_flash_fn = screen_flash
|
||||
return self
|
||||
|
||||
@@ -54,6 +56,8 @@ func propagate_insert_state() -> void:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
if economics_panel:
|
||||
economics_panel.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
@@ -173,6 +177,15 @@ func consume_debug_response() -> void:
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# #824: Forward economy_snapshot from server to the economics panel (D-181).
|
||||
func consume_economy_snapshot() -> void:
|
||||
if GameState.economy_snapshot == null or not economics_panel:
|
||||
return
|
||||
if economics_panel.has_method("receive_economy_data"):
|
||||
economics_panel.receive_economy_data(GameState.economy_snapshot)
|
||||
GameState.economy_snapshot = null
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
func consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
|
||||
@@ -213,6 +213,12 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
GameState.settings_response = null
|
||||
|
||||
# v21: economy_snapshot (#824, D-181)
|
||||
if snapshot.has("economy_snapshot") and snapshot.economy_snapshot is Dictionary:
|
||||
GameState.economy_snapshot = snapshot.economy_snapshot
|
||||
else:
|
||||
GameState.economy_snapshot = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
if (
|
||||
snapshot.has("character_visual_descriptor")
|
||||
|
||||
@@ -225,10 +225,178 @@ func _dispatch(line: String) -> void:
|
||||
_send_debug("ListPopulation")
|
||||
"status":
|
||||
_send_debug("GetContaminationStatus")
|
||||
"econ":
|
||||
_dispatch_econ(parts)
|
||||
_:
|
||||
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
|
||||
|
||||
|
||||
# #825: Economics debug console commands (D-178, D-180, D-181).
|
||||
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
|
||||
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
|
||||
# ship with #823. Until then, _send_debug will transmit the payload but the server will
|
||||
# respond with an "unknown command" error. Wire format is ready; server handler is not.
|
||||
func _dispatch_econ(parts: Array) -> void:
|
||||
if parts.size() < 2:
|
||||
_append_text(
|
||||
(
|
||||
"usage:\n"
|
||||
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
|
||||
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
|
||||
+ " econ inspect <system_id>"
|
||||
),
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
var sub := parts[1].to_lower()
|
||||
match sub:
|
||||
"inject":
|
||||
_econ_inject(parts)
|
||||
"param":
|
||||
_econ_param(parts)
|
||||
"inspect":
|
||||
_econ_inspect(parts)
|
||||
_:
|
||||
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
|
||||
|
||||
|
||||
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||||
# Minimal form: econ inject Sol shock 0.5
|
||||
# Full form: econ inject Sol fusion_fuel boost 1.2 100
|
||||
func _econ_inject(parts: Array) -> void:
|
||||
# parts[0]="econ", parts[1]="inject", rest is args
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 3:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
|
||||
var system_id: String = args[0]
|
||||
var commodity_id: String = ""
|
||||
var effect: String = ""
|
||||
var magnitude_str: String = ""
|
||||
var duration_ticks: int = 0
|
||||
|
||||
var ticks_str := ""
|
||||
if args[1].to_lower() in ["shock", "boost"]:
|
||||
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
|
||||
effect = args[1].to_lower()
|
||||
magnitude_str = args[2]
|
||||
if args.size() >= 4:
|
||||
ticks_str = args[3]
|
||||
elif args.size() == 3:
|
||||
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
|
||||
)
|
||||
return
|
||||
else:
|
||||
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
|
||||
commodity_id = args[1]
|
||||
if args.size() < 4:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
effect = args[2].to_lower()
|
||||
if not effect in ["shock", "boost"]:
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
magnitude_str = args[3]
|
||||
if args.size() >= 5:
|
||||
ticks_str = args[4]
|
||||
|
||||
if not ticks_str.is_empty():
|
||||
if not ticks_str.is_valid_int():
|
||||
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
|
||||
return
|
||||
duration_ticks = int(ticks_str)
|
||||
|
||||
if not magnitude_str.is_valid_float():
|
||||
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
|
||||
return
|
||||
var magnitude: float = float(magnitude_str)
|
||||
|
||||
var payload := {
|
||||
"system_id": system_id,
|
||||
"effect": effect,
|
||||
"magnitude": magnitude,
|
||||
}
|
||||
if not commodity_id.is_empty():
|
||||
payload["commodity_id"] = commodity_id
|
||||
if duration_ticks > 0:
|
||||
payload["duration_ticks"] = duration_ticks
|
||||
|
||||
_append_text(
|
||||
(
|
||||
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
|
||||
% [
|
||||
effect,
|
||||
system_id,
|
||||
" / " + commodity_id if not commodity_id.is_empty() else "",
|
||||
magnitude,
|
||||
duration_ticks
|
||||
]
|
||||
),
|
||||
TEXT_COLOR,
|
||||
)
|
||||
_send_debug({"InjectEconEvent": payload})
|
||||
|
||||
|
||||
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||||
func _econ_param(parts: Array) -> void:
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 2:
|
||||
_append_text(
|
||||
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
var param_name: String = args[0].to_lower()
|
||||
if not param_name in ["alpha", "beta", "friction"]:
|
||||
_append_text(
|
||||
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
|
||||
if not args[1].is_valid_float():
|
||||
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
|
||||
return
|
||||
var value: float = float(args[1])
|
||||
|
||||
var payload := {"param": param_name, "value": value}
|
||||
if args.size() >= 3:
|
||||
payload["system_a"] = args[2]
|
||||
if args.size() >= 4:
|
||||
payload["system_b"] = args[3]
|
||||
|
||||
var scope := "global"
|
||||
if payload.has("system_a") and payload.has("system_b"):
|
||||
scope = "%s ↔ %s" % [payload["system_a"], payload["system_b"]]
|
||||
elif payload.has("system_a"):
|
||||
scope = payload["system_a"]
|
||||
|
||||
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
|
||||
_send_debug({"SetEconParam": payload})
|
||||
|
||||
|
||||
# econ inspect <system_id> — request all 7 D-181 signals for a system
|
||||
func _econ_inspect(parts: Array) -> void:
|
||||
if parts.size() < 3:
|
||||
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
|
||||
return
|
||||
var system_id: String = parts[2]
|
||||
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
|
||||
_send_debug({"GetEconState": system_id})
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := (
|
||||
SimBridge
|
||||
@@ -284,6 +452,14 @@ func _print_help() -> void:
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ "\n"
|
||||
+ "Economics (D-178/D-180/D-181):\n"
|
||||
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
|
||||
+ " — fire an EconEvent at a system\n"
|
||||
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
|
||||
+ " — set tâtonnement parameter\n"
|
||||
+ " econ inspect <sys> — show all 7 signals for a system\n"
|
||||
+ "\n"
|
||||
+ " help — this list"
|
||||
),
|
||||
TEXT_COLOR
|
||||
|
||||
+7
-1
@@ -1,7 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
[ext_resource type="PackedScene" path="res://ui/implant/economics_panel.tscn" id="3_econ"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -19,3 +20,8 @@ script = ExtResource("1_hud")
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
; #824: Economics Monitor — implant/economics INSERT panel. Toggled via E key from main.gd.
|
||||
; Composes ImplantPanel from the D-169 component library. Placeholder data until #822 ships.
|
||||
[node name="EconomicsPanel" parent="." instance=ExtResource("3_econ")]
|
||||
visible = false
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name EconomicsPanel
|
||||
extends Control
|
||||
|
||||
## Economics Monitor — implant insert panel (#824, D-170, D-181).
|
||||
##
|
||||
## Displays price data and GDP for a selected system. Receives economy_snapshot
|
||||
## from the server via snapshot_handler → GameState → snapshot_consumers pipeline.
|
||||
##
|
||||
## Data architecture:
|
||||
## - Ring buffer: last 20 ticks of economy data per system (for trend display)
|
||||
## - 7 D-181 signals per system: price_current, price_trend, trade_flow_volume,
|
||||
## corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio
|
||||
## - Signals 1-2 (price_current, price_trend) are Phase 2 deliverables
|
||||
## - Signals 3-7 are parsed and stored but not yet displayed (Phase 3)
|
||||
##
|
||||
## Visual layer: ImplantPanel composition built in _ready() from component library (D-169).
|
||||
## System selector uses LEFT/RIGHT arrow keys to cycle through all 301 systems.
|
||||
## Placeholder commodity prices shown until #822 ships.
|
||||
|
||||
## Emitted when new economy data arrives for the selected system.
|
||||
signal economy_data_updated(system_id: String, data: Dictionary)
|
||||
|
||||
const APP_PATH := "implant/economics"
|
||||
const RING_BUFFER_SIZE: int = 20
|
||||
const STAR_MAP_DATA := "res://data/star_map_data.json"
|
||||
const PANEL_WIDTH: float = 340.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
|
||||
# Placeholder commodity rows shown until server ships EconomySnapshot (#822).
|
||||
# Commodity IDs match D-184 catalog.
|
||||
const PLACEHOLDER_COMMODITIES: Array[Dictionary] = [
|
||||
{"id": "fusion_fuel", "name": "FUSION FUEL", "price": 142, "trend": 1},
|
||||
{"id": "basic_goods", "name": "BASIC GOODS", "price": 58, "trend": 0},
|
||||
{"id": "machinery", "name": "MACHINERY", "price": 890, "trend": -1},
|
||||
{"id": "organics", "name": "ORGANICS", "price": 34, "trend": 1},
|
||||
{"id": "lattice_comp", "name": "LATTICE COMP.", "price": 2240, "trend": 0},
|
||||
{"id": "pharmaceuticals", "name": "PHARMA", "price": 312, "trend": -1},
|
||||
]
|
||||
|
||||
## Currently selected system for detailed display. Empty = no selection.
|
||||
var selected_system: String = ""
|
||||
|
||||
## Ring buffer: system_id → Array[Dictionary] (most recent last, max RING_BUFFER_SIZE).
|
||||
## Each entry is one tick's worth of D-181 signals for that system.
|
||||
var _history: Dictionary = {}
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
# Visual panel state (D-169 component library)
|
||||
var _panel: ImplantPanel = null # root container
|
||||
var _implant_theme: ImplantTheme = null
|
||||
var _header: ImplantHeader = null # kept for set_content() on system change
|
||||
var _nav_row: ImplantDataRow = null # system selector nav hint
|
||||
var _gdp_row: ImplantDataRow = null # GDP value row
|
||||
var _commodity_rows: Array = [] # ImplantDataRow × 6, updated without full rebuild
|
||||
var _placeholder_notice: ImplantTextBlock = null # hidden once live data arrives
|
||||
|
||||
# System list for the selector (populated from STAR_MAP_DATA)
|
||||
var _systems: Array = [] # Array[Dictionary], sorted by proper_name
|
||||
var _selected_idx: int = 0 # index into _systems
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
# D-170: Register with HUD layer groups
|
||||
HudGroups.register(self, APP_PATH)
|
||||
HudGroups.app_changed.connect(_on_app_changed)
|
||||
|
||||
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
_load_system_list()
|
||||
_build_panel()
|
||||
economy_data_updated.connect(_on_economy_data_updated)
|
||||
|
||||
|
||||
## Called from SnapshotConsumers when economy_snapshot arrives in GameState.
|
||||
## data: Dictionary keyed by system_id → signal payload (D-181).
|
||||
func receive_economy_data(data: Dictionary) -> void:
|
||||
for system_id: String in data:
|
||||
var signals: Variant = data[system_id]
|
||||
if not signals is Dictionary:
|
||||
continue
|
||||
if not _history.has(system_id):
|
||||
_history[system_id] = []
|
||||
var buf: Array = _history[system_id]
|
||||
buf.append(signals)
|
||||
if buf.size() > RING_BUFFER_SIZE:
|
||||
_history[system_id] = buf.slice(buf.size() - RING_BUFFER_SIZE)
|
||||
|
||||
# Notify listeners if the selected system received new data
|
||||
if not selected_system.is_empty() and data.has(selected_system):
|
||||
economy_data_updated.emit(selected_system, data[selected_system])
|
||||
|
||||
|
||||
## Select a system for detailed display. Emits economy_data_updated if history exists.
|
||||
func select_system(system_id: String) -> void:
|
||||
selected_system = system_id
|
||||
if not selected_system.is_empty() and _history.has(selected_system):
|
||||
var buf: Array = _history[selected_system]
|
||||
if buf.size() > 0:
|
||||
economy_data_updated.emit(selected_system, buf[buf.size() - 1])
|
||||
|
||||
|
||||
## Get the full ring buffer for a system (for chart/sparkline rendering).
|
||||
## Returns empty array if no history exists.
|
||||
func get_history(system_id: String) -> Array:
|
||||
return _history.get(system_id, [])
|
||||
|
||||
|
||||
## Get the latest tick's signals for a system, or empty dict.
|
||||
func get_latest(system_id: String) -> Dictionary:
|
||||
var buf: Array = _history.get(system_id, [])
|
||||
if buf.size() > 0:
|
||||
return buf[buf.size() - 1]
|
||||
return {}
|
||||
|
||||
|
||||
## Get all system IDs that have received at least one tick of data.
|
||||
func get_known_systems() -> Array:
|
||||
return _history.keys()
|
||||
|
||||
|
||||
## Toggle via HUD layer system (D-170). INSERT mode — shares screen with gameplay.
|
||||
func toggle_visible() -> void:
|
||||
HudGroups.toggle_app(APP_PATH, HudGroups.Mode.INSERT)
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes (D-170).
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active and HudGroups.is_app_active(APP_PATH):
|
||||
HudGroups.close_app()
|
||||
|
||||
|
||||
## Respond to app layer changes (D-170).
|
||||
func _on_app_changed(app_path: String, mode: int) -> void:
|
||||
if app_path != APP_PATH:
|
||||
return
|
||||
if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT:
|
||||
visible = true
|
||||
else:
|
||||
visible = false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System list — populated from star_map_data.json
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_system_list() -> void:
|
||||
if not FileAccess.file_exists(STAR_MAP_DATA):
|
||||
push_warning("EconomicsPanel: %s not found" % STAR_MAP_DATA)
|
||||
return
|
||||
var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
for node: Dictionary in parsed.get("nodes", []):
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not sid.is_empty():
|
||||
_systems.append(node)
|
||||
_systems.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var na: String = a.get("proper_name", a.get("system_id", ""))
|
||||
var nb: String = b.get("proper_name", b.get("system_id", ""))
|
||||
return na < nb
|
||||
)
|
||||
if not _systems.is_empty():
|
||||
selected_system = _systems[0].get("system_id", "")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Visual panel — D-169 ImplantPanel composition
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_panel() -> void:
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "EconPanel"
|
||||
_panel.theme_resource = _implant_theme
|
||||
_panel.custom_minimum_size.x = PANEL_WIDTH
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
|
||||
add_child(_panel)
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
## Full rebuild of panel components. Called on system change and initial build.
|
||||
func _rebuild_panel() -> void:
|
||||
if not _panel:
|
||||
return
|
||||
_panel.clear()
|
||||
_commodity_rows.clear()
|
||||
|
||||
var node: Dictionary = _current_node()
|
||||
var sys_name: String = node.get("proper_name", node.get("system_id", "—"))
|
||||
var sys_id: String = node.get("system_id", "")
|
||||
var total: int = _systems.size()
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────────
|
||||
_header = ImplantHeader.new("ECONOMICS MONITOR", sys_name)
|
||||
_panel.add_component(_header)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── System selector nav ────────────────────────────────────────────────────
|
||||
var nav_hint := "◄ ► · %s [%d / %d]" % [sys_id, _selected_idx + 1, total]
|
||||
_nav_row = ImplantDataRow.new(nav_hint)
|
||||
_panel.add_component(_nav_row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── GDP strip ─────────────────────────────────────────────────────────────
|
||||
var gdp_str: String = node.get("gdp", "—")
|
||||
_gdp_row = ImplantDataRow.new("gdp " + gdp_str)
|
||||
_panel.add_component(_gdp_row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── Price table ───────────────────────────────────────────────────────────
|
||||
_panel.add_component(ImplantTextBlock.new("MARKET PRICES"))
|
||||
|
||||
var latest: Dictionary = get_latest(sys_id)
|
||||
var commodity_signals: Array = latest.get("price_current", [])
|
||||
|
||||
for c: Dictionary in PLACEHOLDER_COMMODITIES:
|
||||
var cid: String = c.get("id", "")
|
||||
var price: int = c.get("price", 0)
|
||||
var trend: int = c.get("trend", 0)
|
||||
|
||||
# Overlay live data when available (D-181 signal 1-2)
|
||||
for sig: Dictionary in commodity_signals:
|
||||
if sig.get("commodity_id", "") == cid:
|
||||
price = int(sig.get("price_current", price))
|
||||
trend = int(sig.get("price_trend", trend))
|
||||
break
|
||||
|
||||
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
|
||||
var row := ImplantDataRow.new(row_text)
|
||||
_panel.add_component(row)
|
||||
_commodity_rows.append(row)
|
||||
|
||||
# ── Placeholder notice ────────────────────────────────────────────────────
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
var notice_text: String = (
|
||||
"[LIVE MARKET — #822 PENDING]" if _history.is_empty() else "LIVE DATA ACTIVE"
|
||||
)
|
||||
_placeholder_notice = ImplantTextBlock.new(notice_text)
|
||||
_panel.add_component(_placeholder_notice)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("[ ] select system · N close"))
|
||||
|
||||
|
||||
func _current_node() -> Dictionary:
|
||||
if _systems.is_empty():
|
||||
return {}
|
||||
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
|
||||
return _systems[_selected_idx]
|
||||
|
||||
|
||||
func _trend_glyph(trend: int) -> String:
|
||||
if trend > 0:
|
||||
return "▲"
|
||||
if trend < 0:
|
||||
return "▼"
|
||||
return "—"
|
||||
|
||||
|
||||
## Respond to economy_data_updated signal — refresh the price table in-place.
|
||||
func _on_economy_data_updated(system_id: String, data: Dictionary) -> void:
|
||||
if not _panel or _commodity_rows.is_empty():
|
||||
return
|
||||
if system_id != selected_system:
|
||||
return
|
||||
|
||||
var commodity_signals: Array = data.get("price_current", [])
|
||||
|
||||
for i: int in range(PLACEHOLDER_COMMODITIES.size()):
|
||||
if i >= _commodity_rows.size():
|
||||
break
|
||||
var c: Dictionary = PLACEHOLDER_COMMODITIES[i]
|
||||
var cid: String = c.get("id", "")
|
||||
var price: int = c.get("price", 0)
|
||||
var trend: int = c.get("trend", 0)
|
||||
|
||||
for sig: Dictionary in commodity_signals:
|
||||
if sig.get("commodity_id", "") == cid:
|
||||
price = int(sig.get("price_current", price))
|
||||
trend = int(sig.get("price_trend", trend))
|
||||
break
|
||||
|
||||
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
|
||||
_commodity_rows[i].text = row_text
|
||||
|
||||
if _placeholder_notice and not _history.is_empty():
|
||||
_placeholder_notice.text = "LIVE DATA ACTIVE"
|
||||
|
||||
|
||||
## Cycle the system selector by delta steps (+1 or -1).
|
||||
## Called from main.gd _unhandled_key_input — [ and ] keys when panel is active.
|
||||
func navigate(delta: int) -> void:
|
||||
if _systems.is_empty():
|
||||
return
|
||||
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
|
||||
selected_system = _systems[_selected_idx].get("system_id", "")
|
||||
_rebuild_panel()
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/implant/economics_panel.gd" id="1_econ"]
|
||||
|
||||
; #824: Economics Monitor insert panel — price data and GDP for selected system.
|
||||
; Composed from ImplantPanel component library (D-169). Registered under implant/economics (D-170).
|
||||
; Toggle with E key in implant mode. Data flows from EconomySnapshot via snapshot_consumers.
|
||||
; Placeholder commodity prices shown until server ticket #822 ships.
|
||||
|
||||
[node name="EconomicsPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_econ")
|
||||
@@ -135,7 +135,6 @@ func _process(_delta: float) -> void:
|
||||
_dirty = false
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes.
|
||||
## Called from main.gd when insert state changes.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
@@ -507,10 +506,13 @@ func _rebuild_info_panel() -> void:
|
||||
|
||||
# Population + GDP
|
||||
var population: String = node.get("population", "")
|
||||
if not population.is_empty():
|
||||
var gdp: String = node.get("gdp", "")
|
||||
if not population.is_empty() or not gdp.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
_info_panel.add_component(ImplantDataRow.new("GDP —"))
|
||||
if not population.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—")
|
||||
_info_panel.add_component(ImplantDataRow.new(gdp_label))
|
||||
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
# Sprint 34: Pulse — Client Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/client`
|
||||
**Agents:** Stig (UI), Tyre (architecture)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #785 | Add system population and GDP to star map info panel | — |
|
||||
| #824 | Economics insert panel — price history charts and GDP display | #822 (server) |
|
||||
| #825 | Economics debug console commands — event triggers and param sliders | #823 (server) |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-181 (7-signal vocabulary — signals 1-2 are Phase 2: price_current, price_trend), D-180 (event port — the commands #825 fires)
|
||||
- `decisions/architecture.md` — D-169 (implant UI component library — compose from client/ui/implant/), D-170 (HUD visibility groups — economics panel lives in INSERT mode), D-020 (IPC — EconomySnapshot arrives in ObserverSnapshot)
|
||||
|
||||
## Notes
|
||||
|
||||
### #785 — System population and GDP to star map info panel
|
||||
|
||||
When a system is selected in the star map (`client/ui/star_map.gd`, `client/ui/star_map.tscn`), the popup built with ImplantPanel components shows: name, star type, hop distance, corridor, GTTR excerpt, bodies, adjacents. Add two new `ImplantDataRow` entries: `POPULATION` and `GDP`. Data is already in `res://data/star_map_data.json` (regenerated by `tooling/generate-star-map-data.py` from `systems.db`). Check whether population and GDP fields are present in the JSON; if not, update the generation script as part of this ticket. This is standalone — no server dependency. Good warmup ticket; complete it first.
|
||||
|
||||
### #824 — Economics insert panel
|
||||
|
||||
New implant panel: **Economics Monitor**. Lives in INSERT mode (D-170), accessible via implant navigation alongside the star map.
|
||||
|
||||
Scene: `client/ui/implant/economics_panel.tscn` + `client/ui/implant/economics_panel.gd`
|
||||
|
||||
Compose strictly from the existing component library (`client/ui/implant/`):
|
||||
- `ImplantPanel` — root container
|
||||
- `ImplantHeader` — "ECONOMICS MONITOR" title + selected system subtitle
|
||||
- `ImplantSeparator` — section dividers
|
||||
- `ImplantDataRow` — key/value rows for price and GDP data
|
||||
- `ImplantTextBlock` — top commodity summary text
|
||||
|
||||
Layout (three sections):
|
||||
1. **System selector** — searchable/scrollable list of systems (can reuse star map system data). Selecting a system triggers an `EconStateQuery` PlayerAction to the server.
|
||||
2. **Price table** — top 6 commodities for selected system, each as an `ImplantDataRow` with `price_current` and a directional trend indicator (▲ / ▼ / —) derived from `price_trend`.
|
||||
3. **GDP strip** — total economic activity for the system displayed as a single row. Update each time a new `EconomySnapshot` arrives.
|
||||
|
||||
Data flow: `snapshot_handler.gd` receives ObserverSnapshot v21. When `economy_snapshot` is present, forward to `economics_panel.gd` via a signal or direct call. The panel caches the last 20 ticks of price data per system for trend display (ring buffer in GDScript Dictionary).
|
||||
|
||||
Do NOT draw custom canvas sparklines unless time allows — `ImplantDataRow` with a trend arrow is the MVP. The price chart can be a follow-on.
|
||||
|
||||
Register the panel in `client/scripts/autoloads/hud_groups.gd` under path `implant/economics`. Add a keyboard shortcut (e.g. `E` in implant mode) and an entry in the implant navigation menu.
|
||||
|
||||
Blocked by #822 (server must expose EconomySnapshot before the panel has real data). Build the panel with placeholder data first; wire live data once #822 ships.
|
||||
|
||||
### #825 — Economics debug console commands
|
||||
|
||||
The debug console exists at `client/ui/debug_console.gd` + `client/ui/debug_console.tscn`. The console already dispatches `DebugCommandKind` variants via `PlayerAction::DebugCommand` through the IPC bridge.
|
||||
|
||||
Add three new command parsers in `_parse_command()` / `_dispatch_command()`:
|
||||
|
||||
```
|
||||
econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||||
→ InjectEconEvent { system_id, commodity_id, effect, magnitude, duration_ticks }
|
||||
|
||||
econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||||
→ SetEconParam { param, value }
|
||||
|
||||
econ inspect <system_id>
|
||||
→ GetEconState { system_id }
|
||||
```
|
||||
|
||||
`econ inspect` returns all 7 D-181 signals for the system; display in console output log as a multi-line block. `econ inject` and `econ param` print a confirmation + the server's `DebugResponsePayload.text`.
|
||||
|
||||
Update `_print_help()` to include the `econ` command family. Blocked by #823 (server must handle the variants before the client can send them meaningfully, though client-side parsing can be built in parallel).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#785 (star map GDP) — standalone, start here
|
||||
|
||||
#822 (server IPC, Sprint 34/server) → #824 (economics insert panel)
|
||||
#823 (server debug handler, Sprint 34/server) → #825 (debug console commands)
|
||||
|
||||
#824 and #825 are parallel after their respective server blockers clear.
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "feat(ui): economics insert panel and debug console econ commands" \
|
||||
--description "Sprint 34 client work" \
|
||||
--base main --head sprint-34/client
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# Sprint 34: Pulse — Copy Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #814 | Rail infrastructure corporation gap | — |
|
||||
| #695 | Author overheard conversations for remaining 24 zone types | — |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-182 (TOML source of truth — all economics content lives in `wiki/economics/`), D-175 (corporation taxonomy — Tier 1/2/3 structure)
|
||||
- `decisions/content.md` — D-142 (zone-type template architecture — 31 zone types defined), D-139 (composable behavior primitives — overheard conversations are Layer 2 cultural flavor)
|
||||
|
||||
## Notes
|
||||
|
||||
### #814 — Rail infrastructure corporation gap
|
||||
|
||||
**Context:** The corporation validation pipeline (`tooling/economy-db/import_economics.py`) flagged a gap: no existing wiki corporation produces the `rail_infrastructure` commodity. This is a lore-world gap as much as a data gap — rail is the primary intra-continental transit system on inhabited worlds (see `decisions/architecture.md` D-093 for Sova Transit context, `wiki/economics/production_chains.toml` for chain definitions).
|
||||
|
||||
**Deliverable:** Either (a) assign `rail_infrastructure` production to an existing Tier 1 or Tier 2 corporation (MVG — Marvian Gravity Works — is the most plausible candidate given its Tier 1 infrastructure mandate) or (b) create a new corporation if no existing corp fits. Update:
|
||||
- `wiki/economics/corporations.toml` — add production entry
|
||||
- Corresponding wiki corporation page (if new corp: `wiki/corporations/<name>.md`)
|
||||
- Verify `make economy-db` passes after the change
|
||||
|
||||
Do not assign to a Tier 3 regional corp — rail infrastructure is a systemic commodity that should have Tier 1 or Tier 2 backing.
|
||||
|
||||
### #695 — Overheard conversations for remaining 24 zone types
|
||||
|
||||
**Context:** `server/content/global/overheard.ron` currently covers 5 of 29 zone types (~17% coverage). The remaining 24 types need 2-4 role-pair conversations each. These are the passive ambient dialogue lines that play when NPCs are overheard by the player without direct engagement (D-078).
|
||||
|
||||
**Format:** Each conversation entry in `overheard.ron` follows the existing pattern — two role slugs, a setting line (terse, 1 sentence describing where/when), and 3-5 lines of dialogue. Lines should feel naturalistic for the zone type; the NPC pair should be plausible co-workers or passers-by given the zone's economic activity.
|
||||
|
||||
**Zone types to cover:** Check `server/content/global/zone-types/` for the full list. Currently covered: the 5 types already in `overheard.ron` (verify by reading the file). Write 2-4 conversations per remaining zone type. Prioritize the zone types most likely to be visited first in a playthrough: `residential_dense`, `commercial_retail`, `transit_hub`, `office_district`, `industrial_light`.
|
||||
|
||||
**Lore anchors:** Use the wiki cultural pages (`wiki/cultures/`) for voice and slang. Zone types that map to specific planetary environments (agricultural, wilderness) should reflect the relevant culture. Avoid generic SF clichés — these lines should feel like they belong in the Reach.
|
||||
|
||||
**Volume:** 24 zone types × 3 conversations average × 4 lines each = ~288 lines total. Work zone-type by zone-type; commit partial coverage. Do not block on completing all 24 before committing.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#814 (rail corp gap) — standalone
|
||||
#695 (overheard conversations) — standalone, parallel
|
||||
```
|
||||
|
||||
Both tickets are independent and can run in parallel.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "content(economics): rail corp gap and overheard conversation coverage" \
|
||||
--description "Sprint 34 copy work" \
|
||||
--base main --head sprint-34/copy
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
# Sprint 34: Pulse — Joint Briefing
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Sprint: Decisions and Schema
|
||||
|
||||
No blocking pre-sprint decisions required. All economic architecture decisions (D-178 through D-188) are confirmed. #810 (event port) is the technical gate for the full chain — it must be the first ticket the server team starts.
|
||||
|
||||
| Item | Owner | Status |
|
||||
|------|-------|--------|
|
||||
| D-178: Economic Model Architecture | decisions/economics.md | Confirmed |
|
||||
| D-179: Stability Acceptance Criteria | decisions/economics.md | Confirmed |
|
||||
| D-180: Event Input Port | decisions/economics.md | Confirmed |
|
||||
| D-181: Signal Vocabulary | decisions/economics.md | Confirmed |
|
||||
| ObserverSnapshot v21 schema | Server → client | New this sprint (#822) |
|
||||
|
||||
---
|
||||
|
||||
## Sprint Ticket Map
|
||||
|
||||
### Server (sprint-34/server)
|
||||
```
|
||||
#810 Event port implementation
|
||||
→ #821 Integrate econ-sim into server tick loop
|
||||
→ #822 Expose economy state over IPC (ObserverSnapshot v21)
|
||||
→ #823 Economics debug command handler
|
||||
```
|
||||
|
||||
### Client (sprint-34/client)
|
||||
```
|
||||
#785 Star map: system population + GDP (standalone)
|
||||
#822 (server, blocker) → #824 Economics insert panel
|
||||
#823 (server, blocker) → #825 Debug console econ commands
|
||||
```
|
||||
|
||||
### Copy (sprint-34/copy)
|
||||
```
|
||||
#814 Rail infrastructure corporation gap (standalone)
|
||||
#695 Overheard conversations — 24 zone types (standalone, parallel)
|
||||
```
|
||||
|
||||
### Planning (sprint-34/planning)
|
||||
```
|
||||
#811 Brand layer design (early sprint)
|
||||
#748 Phase 3 breakdown workshop (after server tickets in_progress)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Team Integration Points
|
||||
|
||||
**ObserverSnapshot v21 (server → client)**
|
||||
- Server: `server/src/bridge/types.rs` — add `EconomySnapshot` struct, bump `PROTOCOL_VERSION` to 21
|
||||
- Client: `client/scripts/snapshot_handler.gd` — parse `economy_snapshot` field, route to economics panel
|
||||
- Coordination: Server team defines the struct; client team consumes it. Server team ships #822 first; client team builds #824 with placeholder data in the meantime.
|
||||
|
||||
**Debug command flow (both teams)**
|
||||
- Server: `server/src/bridge/types.rs` — add `InjectEconEvent`, `SetEconParam`, `GetEconState` to `DebugCommandKind`
|
||||
- Client: `client/ui/debug_console.gd` — add `econ inject`, `econ param`, `econ inspect` command parsers
|
||||
- Coordination: Server team ships #823 before client team wires #825. Client team can build command parsing and help text independently; just gate the send path on #823 being merged.
|
||||
|
||||
**Star map GDP (#785)**
|
||||
- Client-only ticket. Check whether `res://data/star_map_data.json` already includes `population` and `gdp` fields. If not, update `tooling/generate-star-map-data.py` to include them from `server/data/systems.db`. This is a self-contained warmup — complete before #824.
|
||||
|
||||
---
|
||||
|
||||
## Cascade Enforcement
|
||||
|
||||
**No Phase 4 tickets.** This sprint closes Phase 2 and initiates Phase 3 planning via #748 and #811. The following are explicitly out of scope and must not be started, designed, or discussed:
|
||||
|
||||
- Character creation (#618, #619, #694, #606)
|
||||
- Tycoon starting states (#615)
|
||||
- Bookmark system (#614)
|
||||
- NPC personality surface area (#621)
|
||||
- Apartment generator (#617, #681)
|
||||
- Any ticket under Phase 4 epic #749
|
||||
|
||||
Phase 4 cannot start until Phase 3 delivers (Atlas of the Reach). Phase 3 planning workshop (#748) runs this sprint — but Phase 3 implementation tickets do not start until Sprint 35+.
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
The sprint is done when a developer can do all three of the following in the running game:
|
||||
|
||||
1. **Open the implant economics panel** — select any system, see live price data (price_current and price_trend for at least 6 commodities) updating in real time as the economy runs.
|
||||
2. **Trigger a supply shock from the debug console** — type `econ inject <system_id> shock 0.5 200`, observe the price_current values shift in the economics panel within a few ticks, then recover toward equilibrium over the next 200 ticks.
|
||||
3. **Mutate α from the debug console** — type `econ param alpha 0.01`, observe slower price adjustment in the panel; type `econ param alpha 0.06`, observe faster adjustment.
|
||||
|
||||
None of the above require a character. The economics panel and debug console can be exercised from the main game loop without entering the simulation world — they operate via the IPC bridge on whatever player session is active.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
**Phase alignment:** Sprint 34 is Phase 2 delivery infrastructure. Test focus is economic state correctness over IPC, not simulation model correctness (that was Sprint 33 / D-179 stability tests).
|
||||
|
||||
| Test | Tier | Owner |
|
||||
|------|------|-------|
|
||||
| ObserverSnapshot v21 roundtrip — serialize/deserialize EconomySnapshot | Tier 1 (fixture) | Server |
|
||||
| EconStateQuery → economy_snapshot present in next snapshot | Tier 2 (bridge) | Server |
|
||||
| `econ inject` → DebugResponsePayload.success true + price shift observable | Tier 2 (bridge) | Server |
|
||||
| Economics insert panel renders with fixture EconomySnapshot | Tier 3 (client live) | Client |
|
||||
| Debug console parses `econ inject` without error | Tier 3 (client live) | Client |
|
||||
| Star map popup shows population + GDP fields | Tier 3 (client live) | Client |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking implementation. One design question in-flight:
|
||||
|
||||
- **Q: Brand layer architecture** (#811 planning) — not blocking Sprint 34 implementation work. Resolved by planning team this sprint; produces Phase 3 tickets for Sprint 35.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Sprint 34: Pulse — Planning Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/planning`
|
||||
**Agents:** Gestalt (systems), Burnelli-Sheldon (economics), Tyre (technical), Miri (worldbuilding), Qatux (documenter), SI (project manager)
|
||||
|
||||
## Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #748 | Phase 3: Planetary/moon maps and station layouts — Atlas of the Reach | #747 (in progress → closes this sprint) |
|
||||
| #811 | Brand layer design | — |
|
||||
|
||||
## Context to Read Before Discussion
|
||||
|
||||
For **#811 (Brand layer design):**
|
||||
- `decisions/economics.md` — D-185 (Brands Are Not Commodities), D-184 (Commodity Catalog — what exists), D-173 (Commodity Taxonomy — three-tier structure)
|
||||
- `decisions/scope.md` — D-131 (broad economic verb vocabulary), D-118 (small business owner starting state — brands are the Phase 3 player layer)
|
||||
- `wiki/economics/commodities.toml`, `wiki/economics/production_chains.toml`
|
||||
|
||||
For **#748 (Phase 3 breakdown):**
|
||||
- `CLAUDE.md` — Development cascade table (Phase 3 = Planetary/moon maps, deliverable = Atlas of the Reach)
|
||||
- `decisions/architecture.md` — D-093 (Sova Transit District spatial layout), D-094 (district spatial hierarchy), D-095 (Horizon stations and gate infrastructure)
|
||||
- Sprint 33 deliverable: `server/data/systems.db` populated with 300+ systems, gate links, currency zones
|
||||
- `docs/atlas/` — existing atlas content
|
||||
|
||||
---
|
||||
|
||||
## #811 — Brand Layer Design
|
||||
|
||||
**Type:** Planning discussion — produces a D-record in `decisions/economics.md`
|
||||
|
||||
**What this is:** The brand/luxury goods system sits on top of the commodity layer (D-185 confirms brands are NOT commodities). Brands consume commodities as inputs. Brand pricing is driven by cultural/emotional/want mechanics, not tâtonnement. This design work answers the Phase 3 question: how does a player engage with the economy as a participant (producer/trader/brand-builder) rather than an observer?
|
||||
|
||||
**Discussion rounds:**
|
||||
|
||||
**Round 1 — Inventory (what exists, what is missing)**
|
||||
- What is the full design space of "brand" in the Reach? (Gestalt, Miri)
|
||||
- What D-records already constrain brand design? (Tyre reads economics.md, scope.md)
|
||||
- What is the player's economic verb set when brands exist? (Burnelli-Sheldon, Gestalt)
|
||||
|
||||
**Round 2 — Proposals**
|
||||
- Brand representation: is a brand a DB entity, a modifier on a commodity, or a separate production chain layer? (Tyre, Burnelli-Sheldon)
|
||||
- Cultural pricing model: how does a brand's cultural origin affect demand across currency zones? (Miri, Gestalt)
|
||||
- Player access: what verbs does a player have toward an existing brand vs. founding one? (Gestalt)
|
||||
|
||||
**Round 3 — Convergence**
|
||||
- Draft one D-record covering: brand representation in the data model, cultural demand pricing, player access verbs, and the boundary with Phase 2 commodity tâtonnement
|
||||
- SI creates follow-up implementation tickets for Phase 3 sprint
|
||||
|
||||
**Output:** D-NNN in `decisions/economics.md` (claim ID via `tooling/db/decision claim D economics "Brand layer architecture"`). Qatux files the record. SI creates 2-4 Phase 3 implementation tickets from the decision.
|
||||
|
||||
**CONSTRAINT:** This design session covers brand layer architecture only. No character creation, no tycoon states, no apartment generators. Phase 4 work is out of scope until Phase 3 delivers.
|
||||
|
||||
---
|
||||
|
||||
## #748 — Phase 3 Planetary Maps Breakdown Workshop
|
||||
|
||||
**Type:** Planning discussion — produces a sprint-ready ticket breakdown for Phase 3
|
||||
|
||||
**What this is:** Phase 3 deliverable is the Atlas of the Reach (implant app) — region-level maps at hundreds-of-km scale. Cities, rivers, mountains, rail lines, gate/portal locations, road hierarchy, named areas. This workshop answers: what is the minimal scope for a shippable Phase 3, and what tickets does it generate?
|
||||
|
||||
**Timing:** This discussion runs AFTER the server team confirms Phase 2 is closing (economics in-game, IPC bridge live). Do not start this discussion until Sprint 34 server tickets are at least in_progress.
|
||||
|
||||
**Discussion rounds:**
|
||||
|
||||
**Round 1 — Inventory**
|
||||
- What does Phase 3 require that does not exist? Read `docs/atlas/`, existing world data in `server/data/systems.db`. (Miri, Tyre)
|
||||
- What systems from Phase 2 does Phase 3 build on (gate network, system data, planet_class)? (Gestalt, Tyre)
|
||||
- What is the rendering target? (Implant app panel — same component library as economics panel?) (Tyre)
|
||||
|
||||
**Round 2 — Scope definition**
|
||||
- Define the MVP Atlas: which systems get maps first? (Miri — Sova/Krenn as canonical first-system per D-036)
|
||||
- Data authoring pipeline: how are planetary maps authored? Hand-drawn overlays on procedural heightmaps? Pure procedural? (Miri, Gestalt)
|
||||
- Implant app design: what does the Atlas panel look like? Click-through from the star map? (Tyre)
|
||||
|
||||
**Round 3 — Ticket breakdown**
|
||||
- Break Phase 3 into 4-8 implementation tickets across server, client, copy, visual teams
|
||||
- Assign team and priority to each
|
||||
- SI creates the tickets and blocks them appropriately under #748
|
||||
|
||||
**Output:** 4-8 new tickets (server + client + copy + visual) with team assignments, priorities, and explicit dependencies. SI creates them immediately at round end.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#811 (brand layer design) — run early in sprint, unblocks Phase 3 planning
|
||||
#748 (Phase 3 breakdown) — run after server Sprint 34 tickets are in_progress
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
Planning branch produces decisions and ticket updates only — no code. Commit decisions and close tickets:
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "planning(economics): brand layer design and Phase 3 breakdown" \
|
||||
--description "Sprint 34 planning work" \
|
||||
--base main --head sprint-34/planning
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
# Sprint 34: Pulse — Server Tasks
|
||||
|
||||
**Goal:** Close Phase 2 — wire the economics simulation into the live game, expose price history and trade flows in the implant, and make the economy observable and tweakable at runtime.
|
||||
|
||||
**Branch:** `sprint-34/server`
|
||||
**Agents:** Dudley (simulation), Tyre (architecture)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #810 | Event input port implementation | #809 (done) |
|
||||
| #821 | Integrate econ-sim into game server tick loop | #810 |
|
||||
| #822 | Expose economy state over IPC bridge to client | #821 |
|
||||
| #823 | Economics debug command handler — event injection and parameter mutation | #821 |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/economics.md` — D-178 (model architecture — Leontief + tâtonnement + agents), D-179 (stability criteria), D-180 (event input port — EconEvent struct and visibility modes), D-181 (7-signal vocabulary per node), D-183 (iterative dev cycle)
|
||||
- `decisions/architecture.md` — D-020 (IPC architecture — ObserverSnapshot + PlayerAction), D-031 (tick-to-time mapping — 10 ticks = 1 game-minute)
|
||||
|
||||
## Notes
|
||||
|
||||
### #810 — Event input port implementation
|
||||
|
||||
The EconEvent struct (D-180) must be added to `tooling/econ-sim/src/model.rs` or a new `events.rs` module. The port is the typed interface through which all external disruptions enter the simulation. An event carries:
|
||||
|
||||
```
|
||||
EconEvent {
|
||||
target: Node | NodeSet | Corridor | TradeRoute | Currency | Commodity,
|
||||
effect: ProductivityMultiplier | CapacityMultiplier | DemandShock | ExchangeShock,
|
||||
duration: ticks,
|
||||
visibility: Global | Proximate(hops) | Disclosed(specific_nodes) | Hidden,
|
||||
}
|
||||
```
|
||||
|
||||
Visibility modes are defined in D-180. For this sprint, only `Global` and `Proximate` need to be exercised — `Hidden` is Phase 3 territory (requires the player inspect verb). The port must accept events from: (a) the server tick loop (#821), and (b) debug commands (#823). Test: inject a supply shock, verify cascade propagates and prices recover within 200 ticks per D-179 Test 3.
|
||||
|
||||
### #821 — Integrate econ-sim into game server tick loop
|
||||
|
||||
The econ-sim is currently a standalone CLI binary at `tooling/econ-sim/`. This ticket makes it run inside the server process. Approach:
|
||||
|
||||
1. Extract the simulation logic from `tooling/econ-sim/src/main.rs` into a reusable library crate (e.g. `tooling/econ-sim/src/lib.rs` or a new `server/src/economy/` module — Tyre to decide the crate boundary).
|
||||
2. Add a `bevy_ecs` `System` that advances the economy N ticks per game tick (rate TBD — likely 1 economy tick per 10 game ticks given D-031 tick-to-time mapping).
|
||||
3. Store the current economy state as a `Resource` in bevy_ecs so downstream systems (#822, #823) can query it.
|
||||
4. Economy state must include all 7 D-181 signals per active node so the bridge can later serialize the relevant subset.
|
||||
|
||||
Key files: `server/src/simulation/ticker.rs` (where per-tick systems run), `tooling/econ-sim/src/model.rs` (simulation state), `tooling/econ-sim/src/trade.rs` (tâtonnement step). The DB at `server/data/systems.db` is already populated from Sprint 33.
|
||||
|
||||
Do NOT load the econ DB on every tick — load once at server startup into the bevy_ecs Resource.
|
||||
|
||||
### #822 — Expose economy state over IPC bridge to client
|
||||
|
||||
Extend `ObserverSnapshot` to version 21 with an `economy_snapshot` field:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub economy_snapshot: Option<EconomySnapshot>,
|
||||
```
|
||||
|
||||
`EconomySnapshot` carries per-system data for the client's economics panel (#824). Phase 2 deliverable is D-181 signals 1–2 only (price_current, price_trend). Struct sketch:
|
||||
|
||||
```rust
|
||||
pub struct EconomySnapshot {
|
||||
pub tick: u64,
|
||||
pub nodes: Vec<EconNodeSnapshot>,
|
||||
}
|
||||
|
||||
pub struct EconNodeSnapshot {
|
||||
pub system_id: u32,
|
||||
pub commodity_id: u32,
|
||||
pub price_current: f64,
|
||||
pub price_trend: f64, // delta over last N ticks
|
||||
}
|
||||
```
|
||||
|
||||
Add `EconStateQuery` to the `PlayerAction` enum for on-demand pulls — the client does not need economy data every tick (that would balloon snapshot size). The server responds to `EconStateQuery` by populating `economy_snapshot` on the next snapshot. Without a query, `economy_snapshot` is `None`.
|
||||
|
||||
Update `PROTOCOL_VERSION` to 21 in `server/src/bridge/types.rs`.
|
||||
|
||||
### #823 — Economics debug command handler
|
||||
|
||||
Extend `DebugCommandKind` in `server/src/bridge/types.rs` with three new variants:
|
||||
|
||||
```rust
|
||||
/// Inject an economic event into the running simulation.
|
||||
InjectEconEvent {
|
||||
system_id: u32,
|
||||
commodity_id: Option<u32>, // None = system-wide
|
||||
effect: EconDebugEffect,
|
||||
magnitude: f64,
|
||||
duration_ticks: u32,
|
||||
},
|
||||
/// Mutate a tâtonnement parameter at runtime.
|
||||
SetEconParam {
|
||||
param: EconParamKind, // Alpha | Beta | CorridorFriction { system_a, system_b }
|
||||
value: f64,
|
||||
},
|
||||
/// Return all 7 D-181 signals for a named system.
|
||||
GetEconState {
|
||||
system_id: u32,
|
||||
},
|
||||
```
|
||||
|
||||
Wire these into the existing debug command dispatch in `server/src/simulation/` (wherever `DebugCommandKind` is matched). Return results via `DebugResponsePayload.text` as a human-readable multi-line string. Blocked by #821 (economy resource must exist to query or mutate).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#810 (event port) → #821 (server tick integration) → #822 (IPC exposure)
|
||||
→ #823 (debug command handler)
|
||||
```
|
||||
|
||||
#822 and #823 are parallel after #821 completes.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "feat(simulation): economics in-game tick loop and IPC bridge" \
|
||||
--description "Sprint 34 server work" \
|
||||
--base main --head sprint-34/server
|
||||
```
|
||||
Generated
+13
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -8,10 +8,14 @@
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::{DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer};
|
||||
use crate::bridge::types::{
|
||||
DebugCommandKind, DebugEnabled, DebugResponsePayload, EconDebugEffect, EconParamKind,
|
||||
SnapshotBuffer,
|
||||
};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::economy::{EconSimResource, EconStateResource};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -69,6 +73,8 @@ pub fn handle_debug_commands(
|
||||
(Entity, &TilePosition, Option<&NpcName>),
|
||||
(With<Npc>, With<ActiveSim>, Without<PlayerCharacter>),
|
||||
>,
|
||||
mut econ_sim: Option<ResMut<EconSimResource>>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
) {
|
||||
// Gate: debug must be enabled
|
||||
let enabled = debug_enabled.as_ref().is_some_and(|d| d.0);
|
||||
@@ -325,6 +331,135 @@ pub fn handle_debug_commands(
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::InjectEconEvent {
|
||||
ref target,
|
||||
ref effect,
|
||||
magnitude,
|
||||
duration_ticks,
|
||||
} => {
|
||||
use econ_sim::events::{
|
||||
EconEvent, EconEventEffect, EconEventTarget, EconEventVisibility,
|
||||
};
|
||||
if let Some(ref mut sim) = econ_sim {
|
||||
let econ_effect = match effect {
|
||||
EconDebugEffect::CapacityMultiplier => {
|
||||
EconEventEffect::CapacityMultiplier(magnitude)
|
||||
}
|
||||
EconDebugEffect::ProductivityMultiplier => {
|
||||
EconEventEffect::ProductivityMultiplier(magnitude)
|
||||
}
|
||||
EconDebugEffect::DemandShock => EconEventEffect::DemandShock(magnitude),
|
||||
EconDebugEffect::ExchangeShock => {
|
||||
EconEventEffect::ExchangeShock(magnitude)
|
||||
}
|
||||
};
|
||||
sim.sim.events.push(EconEvent {
|
||||
target: EconEventTarget::Node(target.clone()),
|
||||
effect: econ_effect,
|
||||
duration: duration_ticks,
|
||||
visibility: EconEventVisibility::Global,
|
||||
});
|
||||
DebugResponsePayload {
|
||||
command: format!("InjectEconEvent({}, {:?}, {}×{})", target, effect, magnitude, duration_ticks),
|
||||
text: format!(
|
||||
"Event injected: {:?} ×{} on node '{}' for {} ticks.\nTakes effect on next economy tick.",
|
||||
effect, magnitude, target, duration_ticks
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "InjectEconEvent".to_string(),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::SetEconParam { ref param, value } => {
|
||||
if let Some(ref mut sim) = econ_sim {
|
||||
match param {
|
||||
EconParamKind::TatonnementStep => {
|
||||
let old = sim.sim.alpha;
|
||||
sim.sim.alpha = value;
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(TatonnementStep, {})", value),
|
||||
text: format!("α (tâtonnement step): {} → {}", old, value),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
EconParamKind::DampingFactor => {
|
||||
let old = sim.sim.beta;
|
||||
sim.sim.beta = value;
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(DampingFactor, {})", value),
|
||||
text: format!("β (damping factor): {} → {}", old, value),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
EconParamKind::CorridorFriction { ref corridor_id } => {
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(CorridorFriction({}))", corridor_id),
|
||||
text: "Per-corridor friction override not yet implemented (requires corridor friction model in trade.rs).".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "SetEconParam".to_string(),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::GetEconState { ref system_id } => {
|
||||
if let Some(ref state) = econ_state {
|
||||
let signals: Vec<_> = state
|
||||
.signals
|
||||
.iter()
|
||||
.filter(|((sys, _), _)| sys == system_id)
|
||||
.collect();
|
||||
if signals.is_empty() {
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: format!("System '{}' not found in economy state.", system_id),
|
||||
success: false,
|
||||
}
|
||||
} else {
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"=== Economy state for '{}' (econ_tick={}) ===",
|
||||
system_id, state.econ_tick
|
||||
),
|
||||
format!(" FX rate (Tractus/Mark): {:.4}", state.tractus_mark_rate),
|
||||
];
|
||||
for ((_, commodity_id), sig) in &signals {
|
||||
lines.push(format!(
|
||||
" {} | price={:.2} trend={:+.2} flow={:.1} corps={} stockpile_wks={:.1} prod_vs_base={:.3} coverage={:.2}",
|
||||
commodity_id,
|
||||
sig.price_current,
|
||||
sig.price_trend,
|
||||
sig.trade_flow_volume,
|
||||
sig.corporate_presence,
|
||||
sig.stockpile_weeks,
|
||||
sig.production_vs_baseline,
|
||||
sig.official_coverage_ratio,
|
||||
));
|
||||
}
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: lines.join("\n"),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ impl Plugin for BridgePlugin {
|
||||
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
|
||||
debug::handle_debug_commands
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::economy::tick_economy_simulation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::perception::observer::compute_visibility_geometry
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
|
||||
@@ -320,6 +320,7 @@ mod tests {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +462,7 @@ mod tests {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 20;
|
||||
pub const PROTOCOL_VERSION: u8 = 21;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -81,6 +81,8 @@ pub struct StartupMessage {
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||
/// EconStateQuery PlayerAction variant (#822).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
@@ -216,6 +218,12 @@ pub struct ObserverSnapshot {
|
||||
/// Client reads to confirm setting changes or to populate the settings UI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Economy snapshot (#822, D-181 7-signal snapshot).
|
||||
/// Present for exactly one tick after an `EconStateQuery` is processed.
|
||||
/// Contains all 7 D-181 signals for each commodity in the queried system.
|
||||
/// None during normal gameplay; client queries explicitly via `EconStateQuery`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub economy_snapshot: Option<EconomySnapshot>,
|
||||
}
|
||||
|
||||
/// A single news ticker headline crossing the wire boundary (#591).
|
||||
@@ -550,6 +558,12 @@ pub enum PlayerAction {
|
||||
DeleteSetting {
|
||||
key: String,
|
||||
},
|
||||
/// Query economy state for a named system (#822, D-181).
|
||||
/// Server responds with `ObserverSnapshot.economy_snapshot` for one tick.
|
||||
/// Absent when economy is not loaded or `system_id` is unknown.
|
||||
EconStateQuery {
|
||||
system_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -595,6 +609,21 @@ pub enum DebugCommandKind {
|
||||
ListPopulation,
|
||||
/// Return `ContaminationActive` status and current tick.
|
||||
GetContaminationStatus,
|
||||
/// Inject a D-180 economic event into the running simulation (#823).
|
||||
/// The event fires at the next economy tick and lasts for `duration_ticks`.
|
||||
/// `target` is a system_id (node-level events only in v0.1).
|
||||
InjectEconEvent {
|
||||
target: String,
|
||||
effect: EconDebugEffect,
|
||||
magnitude: f64,
|
||||
duration_ticks: u32,
|
||||
},
|
||||
/// Mutate a simulation parameter at runtime (#823, D-178).
|
||||
/// Changes take effect on the next `Simulation::step()` call.
|
||||
SetEconParam { param: EconParamKind, value: f64 },
|
||||
/// Return all 7 D-181 signals for the named system (#823).
|
||||
/// Equivalent to `EconStateQuery` but via the debug console.
|
||||
GetEconState { system_id: String },
|
||||
}
|
||||
|
||||
/// Debug response payload included in `ObserverSnapshot` (#580).
|
||||
@@ -612,6 +641,72 @@ pub struct DebugResponsePayload {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Wire type for a single commodity's 7 D-181 signals at a node (#822).
|
||||
///
|
||||
/// Compact snapshot used in `EconomySnapshot.nodes`. Mirrors `EconNodeSignals`
|
||||
/// in `simulation::economy` but is Serializable for wire transmission.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconNodeSnapshot {
|
||||
pub commodity_id: String,
|
||||
/// Signal 1: current market price in Tractus (Public).
|
||||
pub price_current: f64,
|
||||
/// Signal 2: price delta over last TREND_WINDOW economy ticks (Public).
|
||||
pub price_trend: f64,
|
||||
/// Signal 3: trade flow volume proxy (Observable).
|
||||
pub trade_flow_volume: f64,
|
||||
/// Signal 4: number of corporations at this node (Observable).
|
||||
pub corporate_presence: u32,
|
||||
/// Signal 5: stockpile in weeks at current demand rate (Semi-private).
|
||||
pub stockpile_weeks: f64,
|
||||
/// Signal 6: supply vs. baseline supply from first tick (Private).
|
||||
pub production_vs_baseline: f64,
|
||||
/// Signal 7: ratio of formal to total activity (Meta-signal).
|
||||
pub official_coverage_ratio: f64,
|
||||
}
|
||||
|
||||
/// Wire type for economy state snapshot (#822, D-181).
|
||||
///
|
||||
/// Returned in `ObserverSnapshot.economy_snapshot` for one tick after an
|
||||
/// `EconStateQuery` is processed. Contains signals for all commodities in the
|
||||
/// queried system. None when economy is not loaded or system_id is unknown.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconomySnapshot {
|
||||
/// The system this snapshot covers.
|
||||
pub system_id: String,
|
||||
/// Economy tick at which this snapshot was produced.
|
||||
pub econ_tick: u64,
|
||||
/// Current Tractus/Mark exchange rate (1.0 = parity).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Signals for each commodity active in this system.
|
||||
pub nodes: Vec<EconNodeSnapshot>,
|
||||
}
|
||||
|
||||
/// Effect type for `InjectEconEvent` debug command (#823, D-180).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EconDebugEffect {
|
||||
/// Multiply production capacity of the target node by `magnitude`.
|
||||
/// < 1.0 = capacity shock; > 1.0 = capacity boost.
|
||||
CapacityMultiplier,
|
||||
/// Multiply productivity of all operations at the target node by `magnitude`.
|
||||
ProductivityMultiplier,
|
||||
/// Add `magnitude` to demand for all commodities at the target node.
|
||||
DemandShock,
|
||||
/// Apply a one-time exchange rate shock of `magnitude` to the FX rate.
|
||||
ExchangeShock,
|
||||
}
|
||||
|
||||
/// Parameter selector for `SetEconParam` debug command (#823, D-178).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EconParamKind {
|
||||
/// Tâtonnement step size (α, D-178 Layer 2). Default: 0.03.
|
||||
TatonnementStep,
|
||||
/// Trade flow damping factor (β, D-178). Default: 0.4.
|
||||
DampingFactor,
|
||||
/// Per-corridor friction override (not yet implemented in simulation).
|
||||
#[allow(dead_code)] // Used by future corridor friction model (#TODO)
|
||||
CorridorFriction { corridor_id: String },
|
||||
}
|
||||
|
||||
/// Whether the debug console is enabled (#580).
|
||||
///
|
||||
/// Set at server startup. Cannot be toggled mid-session via IPC.
|
||||
@@ -941,6 +1036,8 @@ pub struct SnapshotBuffer {
|
||||
pub pending_debug_response: Option<DebugResponsePayload>,
|
||||
/// Pending settings response, consumed once by `compute_observer_snapshot` (#627).
|
||||
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822).
|
||||
pub pending_economy_response: Option<EconomySnapshot>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -328,6 +328,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
|
||||
@@ -394,6 +394,9 @@ pub fn compute_observer_snapshot(
|
||||
None
|
||||
};
|
||||
|
||||
// Consume pending economy snapshot for this tick (#822).
|
||||
let economy_snapshot = buffer.pending_economy_response.take();
|
||||
|
||||
// Consume pending save/load result for this tick (#553).
|
||||
let save_result = buffer.pending_save_result.take();
|
||||
|
||||
@@ -489,6 +492,7 @@ pub fn compute_observer_snapshot(
|
||||
debug_response,
|
||||
current_ticker,
|
||||
settings_response,
|
||||
economy_snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Economy simulation integration — runs econ-sim inside the server tick loop.
|
||||
//!
|
||||
//! Bridges the standalone `econ_sim` library into the Bevy ECS tick loop.
|
||||
//! The simulation advances one economy tick every `ECON_TICK_RATE` game ticks.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - [`EconSimResource`] — holds the running `Simulation` + price history for trends.
|
||||
//! Loaded once at startup from `server/data/systems.db`. Never reloaded mid-session.
|
||||
//!
|
||||
//! - [`EconStateResource`] — all 7 D-181 signals per active (system_id, commodity_id).
|
||||
//! Updated every `ECON_TICK_RATE` game ticks by `tick_economy_simulation`.
|
||||
//! Queryable by the IPC bridge (#822) and debug commands (#823).
|
||||
//!
|
||||
//! - `tick_economy_simulation` — bevy System registered in `SimulationPlugin`.
|
||||
//! Advances one economy tick, then rebuilds `EconStateResource`.
|
||||
//!
|
||||
//! ## Rate (D-031)
|
||||
//!
|
||||
//! `ECON_TICK_RATE = 10` game ticks per economy tick.
|
||||
//! At 10 game ticks/game-minute (D-031), this means the economy advances once
|
||||
//! per game-minute — a reasonable granularity for macro-scale price dynamics.
|
||||
//!
|
||||
//! ## D-181 signals
|
||||
//!
|
||||
//! 1. `price_current` — current market price (Public)
|
||||
//! 2. `price_trend` — Δprice over the last `TREND_WINDOW` economy ticks (Public)
|
||||
//! 3. `trade_flow_volume` — supply volume proxy (Observable; Phase 3 will refine)
|
||||
//! 4. `corporate_presence` — corp count at this node (Observable)
|
||||
//! 5. `stockpile_weeks` — stockpile ÷ weekly demand rate (Semi-private)
|
||||
//! 6. `production_vs_baseline` — supply ÷ initial baseline supply (Private)
|
||||
//! 7. `official_coverage_ratio` — 1 − shadow_intensity (Meta-signal)
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use econ_sim::Simulation;
|
||||
|
||||
use crate::bridge::types::{EconNodeSnapshot, EconomySnapshot, SnapshotBuffer};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Game ticks between each economy tick (D-031: 10 ticks/game-minute).
|
||||
pub const ECON_TICK_RATE: u64 = 10;
|
||||
|
||||
/// Number of economy ticks to average for price trend signal 2 (D-181).
|
||||
const TREND_WINDOW: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The running economics simulation (loaded once at startup).
|
||||
///
|
||||
/// Never reinitialize mid-session — the economy state is continuous.
|
||||
#[derive(Resource)]
|
||||
pub struct EconSimResource {
|
||||
pub sim: Simulation,
|
||||
/// Price history for signal 2 (price_trend) computation.
|
||||
/// Ring-buffer keyed by (system_id, commodity_id) → last TREND_WINDOW prices.
|
||||
price_history: BTreeMap<(String, String), VecDeque<f64>>,
|
||||
/// Baseline supply from first economy tick for signal 6 (production_vs_baseline).
|
||||
baseline_supply: BTreeMap<(String, String), f64>,
|
||||
}
|
||||
|
||||
impl EconSimResource {
|
||||
pub fn new(sim: Simulation) -> Self {
|
||||
Self {
|
||||
sim,
|
||||
price_history: BTreeMap::new(),
|
||||
baseline_supply: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 7 D-181 signals for a single active (system_id, commodity_id) pair.
|
||||
///
|
||||
/// Updated every `ECON_TICK_RATE` game ticks. All fields present when the
|
||||
/// node is active; queries for inactive nodes return nothing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EconNodeSignals {
|
||||
pub system_id: String,
|
||||
pub commodity_id: String,
|
||||
/// Signal 1: current market price in Tractus (Public).
|
||||
pub price_current: f64,
|
||||
/// Signal 2: price delta over last `TREND_WINDOW` economy ticks (Public).
|
||||
/// Positive = price rising; negative = falling. Absolute delta, not percentage.
|
||||
pub price_trend: f64,
|
||||
/// Signal 3: trade flow volume proxy — supply volume this tick (Observable).
|
||||
/// Phase 2 proxy: actual inter-node flow tracking is Phase 3.
|
||||
pub trade_flow_volume: f64,
|
||||
/// Signal 4: number of corporations operating at this node (Observable).
|
||||
pub corporate_presence: u32,
|
||||
/// Signal 5: estimated stockpile in weeks at current demand rate (Semi-private).
|
||||
pub stockpile_weeks: f64,
|
||||
/// Signal 6: supply vs. baseline supply from first tick (Private).
|
||||
/// 1.0 = at baseline; < 1.0 = below baseline; > 1.0 = above.
|
||||
pub production_vs_baseline: f64,
|
||||
/// Signal 7: ratio of formal to total (formal + shadow) activity (Meta-signal).
|
||||
/// Derived from `shadow_intensity`: 1.0 = fully formal, 0.0 = fully shadow.
|
||||
pub official_coverage_ratio: f64,
|
||||
}
|
||||
|
||||
/// Current economy state — all 7 D-181 signals for all active nodes.
|
||||
///
|
||||
/// Updated every `ECON_TICK_RATE` game ticks. Queryable by the IPC bridge
|
||||
/// (#822) and debug command handler (#823). Absent when the economy DB is
|
||||
/// not loaded (graceful degradation).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EconStateResource {
|
||||
/// Economy tick at which this snapshot was produced.
|
||||
pub econ_tick: u64,
|
||||
/// Current Tractus/Mark exchange rate (1.0 = parity).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Signal map: (system_id, commodity_id) → 7-signal snapshot.
|
||||
pub signals: BTreeMap<(String, String), EconNodeSignals>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: advance the economy simulation one tick every `ECON_TICK_RATE` game ticks.
|
||||
///
|
||||
/// Runs after `time::advance_tick` (needs current game tick) and before
|
||||
/// `compute_observer_snapshot` (so signals are fresh for the snapshot).
|
||||
///
|
||||
/// No-op when the game tick is not divisible by `ECON_TICK_RATE`.
|
||||
/// Both `EconSimResource` and `EconStateResource` must be present (inserted
|
||||
/// at startup only when the economy DB loaded successfully).
|
||||
pub fn tick_economy_simulation(
|
||||
time: Res<SimulationTime>,
|
||||
econ_sim_opt: Option<ResMut<EconSimResource>>,
|
||||
econ_state_opt: Option<ResMut<EconStateResource>>,
|
||||
) {
|
||||
let (mut econ_sim, mut econ_state) = match (econ_sim_opt, econ_state_opt) {
|
||||
(Some(s), Some(st)) => (s, st),
|
||||
_ => return, // economy not loaded — no-op
|
||||
};
|
||||
|
||||
if !time.tick.is_multiple_of(ECON_TICK_RATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step the simulation one economy tick
|
||||
econ_sim.sim.step();
|
||||
|
||||
let econ_tick = econ_sim.sim.tick();
|
||||
let fx_rate = econ_sim.sim.tractus_mark_rate();
|
||||
|
||||
// Rebuild signal map from updated node states
|
||||
rebuild_signals(&mut econ_sim, &mut econ_state, econ_tick, fx_rate);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn rebuild_signals(
|
||||
econ_sim: &mut EconSimResource,
|
||||
econ_state: &mut EconStateResource,
|
||||
econ_tick: u64,
|
||||
fx_rate: f64,
|
||||
) {
|
||||
econ_state.econ_tick = econ_tick;
|
||||
econ_state.tractus_mark_rate = fx_rate;
|
||||
econ_state.signals.clear();
|
||||
|
||||
// Collect shadow intensities and corp counts once (avoid repeated borrows)
|
||||
let shadow_intensities: BTreeMap<String, f64> = econ_sim
|
||||
.sim
|
||||
.shadow()
|
||||
.intensity
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
|
||||
let corp_counts: BTreeMap<String, u32> = econ_sim
|
||||
.sim
|
||||
.economy()
|
||||
.presences_by_system
|
||||
.iter()
|
||||
.map(|(sys, corps)| (sys.clone(), corps.len() as u32))
|
||||
.collect();
|
||||
|
||||
// Snapshot current node states into the price_history and baseline_supply maps,
|
||||
// then build signals. We need to separate the borrow from the iteration.
|
||||
let node_snapshots: Vec<(String, Vec<(String, f64, f64, f64, f64)>)> = econ_sim
|
||||
.sim
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(system_id, node)| {
|
||||
let commodities: Vec<(String, f64, f64, f64, f64)> = node
|
||||
.commodities
|
||||
.iter()
|
||||
.map(|(commodity_id, state)| {
|
||||
(
|
||||
commodity_id.clone(),
|
||||
state.price,
|
||||
state.supply,
|
||||
state.stockpile,
|
||||
state.demand,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(system_id.clone(), commodities)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (system_id, commodities) in &node_snapshots {
|
||||
let shadow_intensity = shadow_intensities.get(system_id).copied().unwrap_or(0.0);
|
||||
let corp_count = corp_counts.get(system_id).copied().unwrap_or(0);
|
||||
|
||||
for (commodity_id, price, supply, stockpile, demand) in commodities {
|
||||
let key = (system_id.clone(), commodity_id.clone());
|
||||
|
||||
// Signal 6 baseline: record first-tick supply
|
||||
econ_sim
|
||||
.baseline_supply
|
||||
.entry(key.clone())
|
||||
.or_insert(*supply);
|
||||
let baseline = *econ_sim.baseline_supply.get(&key).unwrap_or(supply);
|
||||
|
||||
// Signal 2: price trend via ring buffer
|
||||
let history = econ_sim.price_history.entry(key.clone()).or_default();
|
||||
history.push_back(*price);
|
||||
if history.len() > TREND_WINDOW {
|
||||
history.pop_front();
|
||||
}
|
||||
let price_trend = if history.len() >= 2 {
|
||||
price - history[0]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Signal 5: stockpile in weeks (7 economy ticks per week approximation)
|
||||
let weekly_demand = demand * 7.0; // 7 econ ticks ≈ 1 week
|
||||
let stockpile_weeks = if weekly_demand > 1e-9 {
|
||||
*stockpile / weekly_demand
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Signal 6: production vs baseline
|
||||
let production_vs_baseline = if baseline > 1e-9 {
|
||||
supply / baseline
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Signal 7: official coverage ratio
|
||||
let official_coverage_ratio = 1.0 - shadow_intensity;
|
||||
|
||||
econ_state.signals.insert(
|
||||
key,
|
||||
EconNodeSignals {
|
||||
system_id: system_id.clone(),
|
||||
commodity_id: commodity_id.clone(),
|
||||
price_current: *price,
|
||||
price_trend,
|
||||
trade_flow_volume: *supply, // Phase 2 proxy
|
||||
corporate_presence: corp_count,
|
||||
stockpile_weeks,
|
||||
production_vs_baseline,
|
||||
official_coverage_ratio,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC query buffer (#822)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pending system_id from an `EconStateQuery` PlayerAction.
|
||||
///
|
||||
/// Populated by `process_player_input`; consumed by `serve_econ_state_query`.
|
||||
/// `None` on ticks when no query was received.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EconQueryBuffer {
|
||||
pub pending: Option<String>,
|
||||
}
|
||||
|
||||
/// System: serve a pending `EconStateQuery` by building an `EconomySnapshot`
|
||||
/// and storing it in `SnapshotBuffer.pending_economy_response`.
|
||||
///
|
||||
/// Runs after `tick_economy_simulation` (signals must be fresh) and before
|
||||
/// `compute_observer_snapshot` (which consumes the response).
|
||||
/// No-op when `EconStateResource` is absent or no query is pending.
|
||||
///
|
||||
/// **D-181 visibility (Phase 2):** All 7 signals are sent unfiltered.
|
||||
/// Phase 3 will gate signals 3–7 behind the D-181 visibility ladder
|
||||
/// (Observable → Semi-private → Private → Meta) based on the player's
|
||||
/// information access at the queried node.
|
||||
pub fn serve_econ_state_query(
|
||||
mut query_buf: ResMut<EconQueryBuffer>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
mut snapshot_buf: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let system_id = match query_buf.pending.take() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let econ_state = match econ_state {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// Economy not loaded — no response (client receives None in snapshot)
|
||||
tracing::debug!(system_id = %system_id, "EconStateQuery: economy not loaded");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Collect signals for all commodities in the requested system
|
||||
let nodes: Vec<EconNodeSnapshot> = econ_state
|
||||
.signals
|
||||
.iter()
|
||||
.filter(|((sys, _), _)| sys == &system_id)
|
||||
.map(|((_, commodity_id), sig)| EconNodeSnapshot {
|
||||
commodity_id: commodity_id.clone(),
|
||||
price_current: sig.price_current,
|
||||
price_trend: sig.price_trend,
|
||||
trade_flow_volume: sig.trade_flow_volume,
|
||||
corporate_presence: sig.corporate_presence,
|
||||
stockpile_weeks: sig.stockpile_weeks,
|
||||
production_vs_baseline: sig.production_vs_baseline,
|
||||
official_coverage_ratio: sig.official_coverage_ratio,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if nodes.is_empty() {
|
||||
tracing::debug!(system_id = %system_id, "EconStateQuery: system not found in economy state");
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot_buf.pending_economy_response = Some(EconomySnapshot {
|
||||
system_id,
|
||||
econ_tick: econ_state.econ_tick,
|
||||
tractus_mark_rate: econ_state.tractus_mark_rate,
|
||||
nodes,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Attempt to load the economy simulation.
|
||||
///
|
||||
/// Returns `Some((EconSimResource, EconStateResource))` on success, `None` on
|
||||
/// failure (with the error logged at warn level). The server inserts these as
|
||||
/// resources when present; the economy features degrade gracefully when absent.
|
||||
pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateResource)> {
|
||||
match Simulation::load_auto(run_seed) {
|
||||
Ok(sim) => {
|
||||
tracing::info!(
|
||||
commodities = sim.economy().commodities.len(),
|
||||
active_nodes = sim.nodes.len(),
|
||||
"Economy simulation loaded"
|
||||
);
|
||||
Some((EconSimResource::new(sim), EconStateResource::default()))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Economy simulation not loaded — economics features disabled for this session"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInpu
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
|
||||
use crate::simulation::economy::EconQueryBuffer;
|
||||
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
|
||||
use crate::simulation::inventory::{
|
||||
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
|
||||
@@ -102,6 +103,7 @@ pub fn process_player_input(
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
|
||||
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
@@ -127,6 +129,7 @@ pub fn process_player_input(
|
||||
| PlayerAction::ChangeSetting { .. }
|
||||
| PlayerAction::RequestAllSettings
|
||||
| PlayerAction::DeleteSetting { .. }
|
||||
| PlayerAction::EconStateQuery { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -410,6 +413,13 @@ pub fn process_player_input(
|
||||
);
|
||||
}
|
||||
}
|
||||
PlayerAction::EconStateQuery { system_id } => {
|
||||
if let Some(ref mut buf) = econ_query_buf {
|
||||
buf.pending = Some(system_id);
|
||||
} else {
|
||||
tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod chunk_streaming;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
pub mod economy;
|
||||
pub mod examine;
|
||||
pub mod follow;
|
||||
pub mod generator;
|
||||
@@ -166,6 +167,28 @@ impl Plugin for SimulationPlugin {
|
||||
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
|
||||
app.init_resource::<ticker::TickerPool>();
|
||||
|
||||
// Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent.
|
||||
// Uses seed 0 for now; will be threaded through StartupMessage world seed (#826).
|
||||
if let Some((econ_sim, econ_state)) = economy::try_load_economy(0) {
|
||||
app.insert_resource(econ_sim).insert_resource(econ_state);
|
||||
}
|
||||
// EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted
|
||||
// even when the economy DB is absent (queries just produce no response).
|
||||
app.init_resource::<economy::EconQueryBuffer>();
|
||||
// tick_economy_simulation + serve_econ_state_query use Option<ResMut<...>> — safe to
|
||||
// register unconditionally. They no-op when EconSimResource / EconStateResource absent.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
economy::tick_economy_simulation
|
||||
.after(time::advance_tick)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
economy::serve_econ_state_query
|
||||
.after(economy::tick_economy_simulation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 20,
|
||||
PROTOCOL_VERSION, 21,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! D-180: Event input port for the economics simulation.
|
||||
//!
|
||||
//! External disruptions enter the simulation through `EconEvent` structs
|
||||
//! pushed into an `EventPort`. Active events are applied each tick via
|
||||
//! `compute_modifiers()`, which builds combined multiplier maps consumed
|
||||
//! by the simulation step.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! ```text
|
||||
//! port.activate_scheduled(tick) // inject any events due this tick
|
||||
//! let mods = port.compute_modifiers(&economy)
|
||||
//! step_inner(..., &mods) // apply production/demand/capacity mods
|
||||
//! currency.apply_exchange_shock(mods.exchange_shock)
|
||||
//! port.advance_remaining() // decrement and expire finished events
|
||||
//! ```
|
||||
//!
|
||||
//! ## Visibility modes (D-180)
|
||||
//!
|
||||
//! Phase 2 exercises `Global` and `Proximate` only.
|
||||
//! `Hidden` is implemented but not exercised until the player inspect verb
|
||||
//! exists (Phase 3).
|
||||
//!
|
||||
//! ## Economics is a receiver, not an emitter (D-180)
|
||||
//!
|
||||
//! The economics layer accepts events; it does NOT generate them.
|
||||
//! Drama comes from the storyteller, political, or disaster layers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::db::Economy;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types (D-180)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The scope of nodes affected by an economic event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EconEventTarget {
|
||||
/// A single market node (system_id).
|
||||
Node(String),
|
||||
/// An explicit set of market nodes.
|
||||
// Used by debug command handler (#823) and storyteller layer (#821+):
|
||||
#[allow(dead_code)]
|
||||
NodeSet(Vec<String>),
|
||||
/// All systems in a named cultural corridor.
|
||||
// Used by storyteller / disaster layer (#821+):
|
||||
#[allow(dead_code)]
|
||||
Corridor(String),
|
||||
/// Both endpoints of a gate link (directed: from → to).
|
||||
// Used by trade disruption events (#821+):
|
||||
#[allow(dead_code)]
|
||||
TradeRoute { from: String, to: String },
|
||||
/// All systems in a currency zone (`"TRACTUS_PRIMARY"`, `"MARK_PRIMARY"`, `"MIXED"`).
|
||||
// Used by currency-zone events (#821+):
|
||||
#[allow(dead_code)]
|
||||
Currency(String),
|
||||
/// A specific commodity at all active nodes.
|
||||
// Used by supply chain disruption events (#821+):
|
||||
#[allow(dead_code)]
|
||||
Commodity(String),
|
||||
}
|
||||
|
||||
/// The economic effect applied at the targeted nodes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EconEventEffect {
|
||||
/// Multiply per-corp productivity (`prod.for_tier()`) by this factor.
|
||||
/// `< 1.0` = disruption; `> 1.0` = boom.
|
||||
// Used by #823 (debug commands) and storyteller events (#821+):
|
||||
#[allow(dead_code)]
|
||||
ProductivityMultiplier(f64),
|
||||
/// Multiply production capacity (`BASELINE_CAPACITY`) by this factor.
|
||||
/// `< 1.0` = capacity constraint; `> 1.0` = expanded capacity.
|
||||
CapacityMultiplier(f64),
|
||||
/// Multiply consumer demand by this factor at affected nodes.
|
||||
/// `> 1.0` = demand spike; `< 1.0` = demand collapse.
|
||||
// Used by #823 (debug commands) and storyteller events (#821+):
|
||||
#[allow(dead_code)]
|
||||
DemandShock(f64),
|
||||
/// Additive delta applied to the Tractus/Mark exchange rate each tick.
|
||||
/// Positive = Tractus strengthens (Mark weakens).
|
||||
// Used by #823 (debug commands) and currency events (#821+):
|
||||
#[allow(dead_code)]
|
||||
ExchangeShock(f64),
|
||||
}
|
||||
|
||||
/// Who can observe this event (D-180 visibility modes).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EconEventVisibility {
|
||||
/// All actors know immediately.
|
||||
Global,
|
||||
/// Visible to nodes within N gate hops of the target.
|
||||
// Used by Proximate event propagation (#821+):
|
||||
#[allow(dead_code)]
|
||||
Proximate(u32),
|
||||
/// Only the named system IDs are informed.
|
||||
// Used by intel/corporate disclosure events (#821+):
|
||||
#[allow(dead_code)]
|
||||
Disclosed(Vec<String>),
|
||||
/// Creates observable price effects but no knowledge flag.
|
||||
/// No actor knows the cause. Phase 3 only — requires player inspect verb.
|
||||
// Used by hidden disruption events (Phase 3, #831+):
|
||||
#[allow(dead_code)]
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// A typed economic disruption event (D-180).
|
||||
///
|
||||
/// Push into an `EventPort` via `push()` (immediate) or `push_at()` (scheduled).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EconEvent {
|
||||
pub target: EconEventTarget,
|
||||
pub effect: EconEventEffect,
|
||||
/// Duration in simulation ticks (clamped to ≥ 1 on push).
|
||||
pub duration: u32,
|
||||
/// Who can observe this event. Used by the information boundary system (#822+).
|
||||
#[allow(dead_code)]
|
||||
pub visibility: EconEventVisibility,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EventPort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ActiveEvent {
|
||||
event: EconEvent,
|
||||
/// Ticks remaining before this event expires.
|
||||
remaining_ticks: u32,
|
||||
}
|
||||
|
||||
struct ScheduledEvent {
|
||||
/// The simulation tick at which to activate this event.
|
||||
inject_at_tick: u64,
|
||||
event: EconEvent,
|
||||
}
|
||||
|
||||
/// The event input port — a typed queue of active and scheduled disruptions.
|
||||
///
|
||||
/// **Usage in the tick loop:**
|
||||
/// 1. Call `activate_scheduled(tick)` at the START of each tick.
|
||||
/// 2. Call `compute_modifiers(&economy)` to get this tick's modifier maps.
|
||||
/// 3. Pass the modifiers to `step_inner`.
|
||||
/// 4. Call `advance_remaining()` at the END of each tick.
|
||||
#[derive(Default)]
|
||||
pub struct EventPort {
|
||||
active: Vec<ActiveEvent>,
|
||||
scheduled: Vec<ScheduledEvent>,
|
||||
}
|
||||
|
||||
impl EventPort {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Inject an event that starts at the current tick.
|
||||
pub fn push(&mut self, event: EconEvent) {
|
||||
let remaining = event.duration.max(1);
|
||||
self.active.push(ActiveEvent {
|
||||
event,
|
||||
remaining_ticks: remaining,
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule an event to be injected at a specific simulation tick.
|
||||
///
|
||||
/// The event becomes active at the START of `inject_at_tick`, before
|
||||
/// `compute_modifiers` is called for that tick.
|
||||
pub fn push_at(&mut self, inject_at_tick: u64, event: EconEvent) {
|
||||
self.scheduled.push(ScheduledEvent {
|
||||
inject_at_tick,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
/// Activate any events scheduled for `current_tick`.
|
||||
///
|
||||
/// Call at the START of each tick, before `compute_modifiers`.
|
||||
pub fn activate_scheduled(&mut self, current_tick: u64) {
|
||||
// Stable Rust: partition scheduled list manually (no drain_filter).
|
||||
let mut still_pending = Vec::new();
|
||||
let mut to_activate = Vec::new();
|
||||
for se in self.scheduled.drain(..) {
|
||||
if se.inject_at_tick <= current_tick {
|
||||
to_activate.push(se.event);
|
||||
} else {
|
||||
still_pending.push(se);
|
||||
}
|
||||
}
|
||||
self.scheduled = still_pending;
|
||||
for event in to_activate {
|
||||
self.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement remaining ticks and remove events that have expired.
|
||||
///
|
||||
/// Call at the END of each tick, after effects have been applied.
|
||||
pub fn advance_remaining(&mut self) {
|
||||
for ae in &mut self.active {
|
||||
ae.remaining_ticks = ae.remaining_ticks.saturating_sub(1);
|
||||
}
|
||||
self.active.retain(|ae| ae.remaining_ticks > 0);
|
||||
}
|
||||
|
||||
/// True when no events are active or scheduled.
|
||||
// Used by tick loop optimization in #821:
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.active.is_empty() && self.scheduled.is_empty()
|
||||
}
|
||||
|
||||
/// Compute combined modifier maps from all currently active events.
|
||||
///
|
||||
/// Multiple overlapping events compound multiplicatively for `f64` effects.
|
||||
/// Exchange shocks accumulate additively.
|
||||
pub fn compute_modifiers(&self, economy: &Economy) -> EventModifiers {
|
||||
let mut mods = EventModifiers::default();
|
||||
|
||||
for ae in &self.active {
|
||||
let commodity_filter: Option<String> = match &ae.event.target {
|
||||
EconEventTarget::Commodity(cid) => Some(cid.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let affected_nodes = resolve_target_nodes(economy, &ae.event.target);
|
||||
|
||||
match ae.event.effect {
|
||||
EconEventEffect::DemandShock(f) => {
|
||||
for node_id in &affected_nodes {
|
||||
apply_multiplier(
|
||||
&mut mods.demand,
|
||||
node_id,
|
||||
&commodity_filter,
|
||||
economy,
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
EconEventEffect::ProductivityMultiplier(f) => {
|
||||
for node_id in &affected_nodes {
|
||||
apply_multiplier(
|
||||
&mut mods.productivity,
|
||||
node_id,
|
||||
&commodity_filter,
|
||||
economy,
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
EconEventEffect::CapacityMultiplier(f) => {
|
||||
for node_id in &affected_nodes {
|
||||
apply_multiplier(
|
||||
&mut mods.capacity,
|
||||
node_id,
|
||||
&commodity_filter,
|
||||
economy,
|
||||
f,
|
||||
);
|
||||
}
|
||||
}
|
||||
EconEventEffect::ExchangeShock(delta) => {
|
||||
mods.exchange_shock += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mods
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EventModifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Combined per-tick modifiers from all currently active events.
|
||||
///
|
||||
/// Missing entries default to `1.0` (multiplicative identity) via the `_for` methods.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EventModifiers {
|
||||
/// `(system_id, commodity_id)` → combined demand multiplier (product of all active shocks).
|
||||
pub demand: BTreeMap<(String, String), f64>,
|
||||
/// `(system_id, commodity_id)` → combined productivity multiplier.
|
||||
pub productivity: BTreeMap<(String, String), f64>,
|
||||
/// `(system_id, commodity_id)` → combined capacity multiplier.
|
||||
pub capacity: BTreeMap<(String, String), f64>,
|
||||
/// Additive delta applied to the Tractus/Mark exchange rate this tick.
|
||||
pub exchange_shock: f64,
|
||||
}
|
||||
|
||||
impl EventModifiers {
|
||||
/// Combined demand multiplier for `(system_id, commodity_id)`.
|
||||
/// Returns `1.0` if no active demand shock targets this pair.
|
||||
/// Guards String allocation: fast-path returns 1.0 when no demand events are active.
|
||||
pub fn demand_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
||||
if self.demand.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
*self
|
||||
.demand
|
||||
.get(&(system_id.to_string(), commodity_id.to_string()))
|
||||
.unwrap_or(&1.0)
|
||||
}
|
||||
|
||||
/// Combined productivity multiplier for `(system_id, commodity_id)`.
|
||||
/// Guards String allocation: fast-path returns 1.0 when no productivity events are active.
|
||||
pub fn productivity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
||||
if self.productivity.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
*self
|
||||
.productivity
|
||||
.get(&(system_id.to_string(), commodity_id.to_string()))
|
||||
.unwrap_or(&1.0)
|
||||
}
|
||||
|
||||
/// Combined capacity multiplier for `(system_id, commodity_id)`.
|
||||
/// Guards String allocation: fast-path returns 1.0 when no capacity events are active.
|
||||
pub fn capacity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
||||
if self.capacity.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
*self
|
||||
.capacity
|
||||
.get(&(system_id.to_string(), commodity_id.to_string()))
|
||||
.unwrap_or(&1.0)
|
||||
}
|
||||
|
||||
/// True when no events are affecting this tick (all maps empty, no exchange shock).
|
||||
// Used by tick loop fast path in #821:
|
||||
#[allow(dead_code)]
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.demand.is_empty()
|
||||
&& self.productivity.is_empty()
|
||||
&& self.capacity.is_empty()
|
||||
&& self.exchange_shock == 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve which system IDs are affected by the given event target.
|
||||
fn resolve_target_nodes(economy: &Economy, target: &EconEventTarget) -> Vec<String> {
|
||||
match target {
|
||||
EconEventTarget::Node(id) => vec![id.clone()],
|
||||
EconEventTarget::NodeSet(ids) => ids.clone(),
|
||||
EconEventTarget::Corridor(corridor) => economy
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| s.cultural_corridor.as_deref() == Some(corridor.as_str()))
|
||||
.map(|s| s.system_id.clone())
|
||||
.collect(),
|
||||
EconEventTarget::TradeRoute { from, to } => vec![from.clone(), to.clone()],
|
||||
EconEventTarget::Currency(zone) => economy
|
||||
.systems
|
||||
.values()
|
||||
.filter(|s| &s.currency_zone == zone)
|
||||
.map(|s| s.system_id.clone())
|
||||
.collect(),
|
||||
// Commodity target: effect applies to this commodity at all active nodes.
|
||||
// The commodity filter is applied during apply_multiplier.
|
||||
EconEventTarget::Commodity(_) => economy.systems.keys().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a multiplier to all `(node, commodity)` pairs matching the filter.
|
||||
///
|
||||
/// If `commodity_filter` is `None`, applies to ALL commodities at `node_id`.
|
||||
/// Multiple events compound multiplicatively.
|
||||
fn apply_multiplier(
|
||||
map: &mut BTreeMap<(String, String), f64>,
|
||||
node_id: &str,
|
||||
commodity_filter: &Option<String>,
|
||||
economy: &Economy,
|
||||
factor: f64,
|
||||
) {
|
||||
let commodity_ids: Vec<String> = match commodity_filter {
|
||||
Some(cid) => vec![cid.clone()],
|
||||
None => economy.commodities.iter().map(|c| c.id.clone()).collect(),
|
||||
};
|
||||
for cid in commodity_ids {
|
||||
let entry = map
|
||||
.entry((node_id.to_string(), cid))
|
||||
.or_insert(1.0);
|
||||
*entry *= factor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! econ_sim — Settled Reach economics simulation library.
|
||||
//!
|
||||
//! Exposes the Layer 1+2+3 tâtonnement simulation as a reusable library crate.
|
||||
//! The standalone `econ-sim` binary uses the same modules independently.
|
||||
//!
|
||||
//! ## Entry points
|
||||
//!
|
||||
//! - [`Simulation`] — stateful per-tick runner for server integration (#821).
|
||||
//! Initialize once, call `step()` each economy tick.
|
||||
//!
|
||||
//! - [`model::run_with_events`] — batch runner (runs N ticks, returns TickRecords).
|
||||
//! Used by the standalone binary and stability checks.
|
||||
//!
|
||||
//! ## Key decisions
|
||||
//!
|
||||
//! - D-178: Economic model architecture (Leontief + tâtonnement + agents)
|
||||
//! - D-180: Event input port (`EconEvent`, `EventPort`)
|
||||
//! - D-181: 7-signal vocabulary per active node
|
||||
|
||||
pub mod agents;
|
||||
pub mod currency;
|
||||
pub mod db;
|
||||
pub mod events;
|
||||
pub mod model;
|
||||
pub mod prng;
|
||||
pub mod seed;
|
||||
pub mod trade;
|
||||
|
||||
mod sim;
|
||||
pub use sim::Simulation;
|
||||
+130
-41
@@ -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: ¤cy::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()
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Stateful simulation runner for server integration (#821).
|
||||
//!
|
||||
//! [`Simulation`] wraps all simulation state (economy data, node states,
|
||||
//! currency, events) and exposes a per-tick `step()` method. This is the
|
||||
//! entry point for the game server's economy system, which advances one
|
||||
//! economy tick per ECON_TICK_RATE game ticks (D-031).
|
||||
//!
|
||||
//! The batch `model::run_with_events` is retained for the CLI binary and
|
||||
//! stability checks. Both share the same underlying `model::step_inner`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{agents, currency, db, events, model, seed, trade};
|
||||
|
||||
/// Stateful Settled Reach economics simulation.
|
||||
///
|
||||
/// Initialize with [`Simulation::load`] once at server startup.
|
||||
/// Call [`Simulation::step`] once per economy tick.
|
||||
pub struct Simulation {
|
||||
pub economy: db::Economy,
|
||||
productivity: BTreeMap<(String, String), seed::Productivity>,
|
||||
shadow: currency::ShadowEconomy,
|
||||
adjacency: BTreeMap<String, Vec<String>>,
|
||||
archetypes: BTreeMap<String, agents::Archetype>,
|
||||
pub nodes: BTreeMap<String, model::NodeState>,
|
||||
currency_state: currency::CurrencyState,
|
||||
/// The event input port (D-180). Push events here; they are consumed
|
||||
/// on the next `step()` call.
|
||||
pub events: events::EventPort,
|
||||
/// Number of economy ticks processed so far.
|
||||
tick: u64,
|
||||
/// Tâtonnement step size (α). Runtime-tunable via SetEconParam (#823).
|
||||
/// Default: `model::ALPHA` (0.03).
|
||||
pub alpha: f64,
|
||||
/// Trade flow damping factor (β). Runtime-tunable via SetEconParam (#823).
|
||||
/// Default: `trade::BETA` (0.4).
|
||||
pub beta: f64,
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
/// Load economy data from `db_path` and initialize the simulation.
|
||||
///
|
||||
/// `run_seed` is the per-run PRNG seed for productivity seeding (D-176).
|
||||
/// This is typically the game's world seed from `StartupMessage`.
|
||||
///
|
||||
/// The DB is opened once and the loaded data stored in memory.
|
||||
/// Do NOT call this per tick.
|
||||
pub fn load(db_path: &Path, run_seed: u64) -> Result<Self, String> {
|
||||
let db_pathbuf = db_path.to_path_buf();
|
||||
if !db_path.exists() {
|
||||
return Err(format!("economy DB not found: {}", db_path.display()));
|
||||
}
|
||||
|
||||
let conn = db::open_db(&db_pathbuf);
|
||||
let economy = db::load_economy(&conn);
|
||||
let productivity = seed::seed_all_productivity(&economy, run_seed);
|
||||
let shadow = currency::seed_shadow_economy(&economy, run_seed);
|
||||
let adjacency = trade::build_adjacency(&economy);
|
||||
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
|
||||
let nodes = model::init_nodes(&economy);
|
||||
|
||||
Ok(Simulation {
|
||||
economy,
|
||||
productivity,
|
||||
shadow,
|
||||
adjacency,
|
||||
archetypes,
|
||||
nodes,
|
||||
currency_state: currency::CurrencyState::new(),
|
||||
events: events::EventPort::new(),
|
||||
tick: 0,
|
||||
alpha: model::ALPHA,
|
||||
beta: trade::BETA,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to load from the auto-detected DB path (same search as the CLI binary).
|
||||
///
|
||||
/// Searches up from CWD for `server/data/systems.db`.
|
||||
pub fn load_auto(run_seed: u64) -> Result<Self, String> {
|
||||
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
let candidate = dir.join("server").join("data").join("systems.db");
|
||||
if candidate.exists() {
|
||||
return Self::load(&candidate, run_seed);
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also check adjacent `data/` directory (when running from within server/)
|
||||
let candidate = std::path::PathBuf::from("data").join("systems.db");
|
||||
if candidate.exists() {
|
||||
return Self::load(&candidate, run_seed);
|
||||
}
|
||||
Err("cannot find server/data/systems.db — pass path explicitly or run from project root".to_string())
|
||||
}
|
||||
|
||||
/// Advance the simulation by one economy tick.
|
||||
///
|
||||
/// Applies active events, runs the Layer 1+2+3 step, and advances the
|
||||
/// event port. Call once per economy tick (every ECON_TICK_RATE game ticks).
|
||||
pub fn step(&mut self) {
|
||||
// Activate any events scheduled for this tick (D-180)
|
||||
self.events.activate_scheduled(self.tick);
|
||||
|
||||
let mods = self.events.compute_modifiers(&self.economy);
|
||||
|
||||
model::step_inner(
|
||||
&self.economy,
|
||||
&self.productivity,
|
||||
&self.shadow,
|
||||
&self.archetypes,
|
||||
&mut self.nodes,
|
||||
&mods,
|
||||
self.alpha,
|
||||
);
|
||||
|
||||
self.currency_state.apply_exchange_shock(mods.exchange_shock);
|
||||
trade::trade_step(
|
||||
&self.economy,
|
||||
&mut self.nodes,
|
||||
&self.adjacency,
|
||||
&mut self.currency_state,
|
||||
self.beta,
|
||||
);
|
||||
self.currency_state.update_rate();
|
||||
|
||||
// Expire finished events
|
||||
self.events.advance_remaining();
|
||||
|
||||
self.tick += 1;
|
||||
}
|
||||
|
||||
/// Number of economy ticks processed so far.
|
||||
pub fn tick(&self) -> u64 {
|
||||
self.tick
|
||||
}
|
||||
|
||||
/// Current Tractus/Mark exchange rate.
|
||||
pub fn tractus_mark_rate(&self) -> f64 {
|
||||
self.currency_state.tractus_mark_rate
|
||||
}
|
||||
|
||||
/// Read-only access to the loaded economy data.
|
||||
pub fn economy(&self) -> &db::Economy {
|
||||
&self.economy
|
||||
}
|
||||
|
||||
/// Read-only access to the per-node shadow economy intensities.
|
||||
pub fn shadow(&self) -> ¤cy::ShadowEconomy {
|
||||
&self.shadow
|
||||
}
|
||||
}
|
||||
@@ -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((
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user