Compare commits
@@ -6,6 +6,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.34] — 2026-04-10
|
||||
|
||||
### Added
|
||||
- Economics simulation integrated into server tick loop — econ-sim library crate, D-180 event port, D-181 7-signal vocabulary, IPC bridge (protocol v21), debug commands (#810, #821, #822, #823)
|
||||
- Economics Monitor implant panel — system selector, 6-commodity price table with trend arrows, GDP strip (#824)
|
||||
- Debug console `econ inject`, `econ param`, `econ inspect` commands for runtime economics manipulation (#825)
|
||||
- Star map info panel shows system population and GDP when data is available (#785)
|
||||
- Overheard conversations for all 31 zone types (was 5): 78 new ambient dialogue entries (94 total) with D-078 occlusion-resilient authoring, investigative knowledge payloads, and culture-neutral role-pair conversations (#695)
|
||||
- D-189 brand layer architecture — administered pricing, halo/volume tiers, 8 brand categories, corp tax/GDP
|
||||
- D-190 brand volume calibration — population-relative scale for ~80B Reach
|
||||
- D-191 Atlas of the Reach Phase 3 scope — sequential settlement growth, Gemma 2 naming pipeline, 9 MVP overlays
|
||||
|
||||
## [v0.1.33] — 2026-04-08
|
||||
|
||||
### Added
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,12 @@ var ai_enhanced_dialogue_enabled: bool = true
|
||||
# "full" response hydrates ai_enhanced_dialogue_enabled (server is authoritative for persisted state).
|
||||
var settings_response: Variant = null
|
||||
|
||||
# v21 fields (#824, D-181): Economy snapshot from server.
|
||||
# Dictionary keyed by system_id → { price_current, price_trend, trade_flow_volume,
|
||||
# corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio }
|
||||
# Null when no economy data in the current snapshot.
|
||||
var economy_snapshot: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ extends Node
|
||||
## "implant/map/starchart" — star map navigator
|
||||
## "implant/wiki/gttr" — Drifter's Guide reader
|
||||
## "implant/journal" — knowledge journal
|
||||
## "implant/economics" — economics monitor (D-181, #824)
|
||||
##
|
||||
## Usage:
|
||||
## HudGroups.register(self, "implant/map/starchart")
|
||||
|
||||
+15
-1
@@ -9,7 +9,6 @@ var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
|
||||
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@@ -34,6 +33,7 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
@onready var economics_panel = $InsertOverlay/HUD/EconomicsPanel # #824: economics monitor (D-170)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -92,6 +92,7 @@ func _ready() -> void:
|
||||
"interaction_prompt": interaction_prompt,
|
||||
"minimap": minimap,
|
||||
"star_map": star_map,
|
||||
"economics_panel": economics_panel,
|
||||
},
|
||||
_screen_flash
|
||||
)
|
||||
@@ -154,6 +155,7 @@ func _ready() -> void:
|
||||
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
|
||||
_router.register("save_result", _consumers.consume_save_result)
|
||||
_router.register("debug_response", _consumers.consume_debug_response)
|
||||
_router.register("economy_snapshot", _consumers.consume_economy_snapshot)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle
|
||||
if settings_dialog and debug_console:
|
||||
@@ -170,6 +172,18 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.keycode == KEY_M:
|
||||
if star_map:
|
||||
star_map.toggle_visible()
|
||||
elif event is InputEventKey and event.keycode == KEY_N:
|
||||
# #824: N — toggle Economics Monitor implant panel (E is bound to interact)
|
||||
if economics_panel:
|
||||
economics_panel.toggle_visible()
|
||||
elif event is InputEventKey and event.keycode == KEY_BRACKETLEFT:
|
||||
# #824: [ — cycle economics panel system selector backward
|
||||
if economics_panel and HudGroups.is_app_active("implant/economics"):
|
||||
economics_panel.navigate(-1)
|
||||
elif event is InputEventKey and event.keycode == KEY_BRACKETRIGHT:
|
||||
# #824: ] — cycle economics panel system selector forward
|
||||
if economics_panel and HudGroups.is_app_active("implant/economics"):
|
||||
economics_panel.navigate(1)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
@@ -17,6 +17,7 @@ var interaction_list: Node = null
|
||||
var interaction_prompt: Node = null
|
||||
var minimap: Node = null
|
||||
var star_map: Node = null
|
||||
var economics_panel: Node = null # #824: economics monitor (D-181)
|
||||
|
||||
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
|
||||
|
||||
@@ -37,6 +38,7 @@ func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
|
||||
interaction_prompt = refs.get("interaction_prompt")
|
||||
minimap = refs.get("minimap")
|
||||
star_map = refs.get("star_map")
|
||||
economics_panel = refs.get("economics_panel")
|
||||
_screen_flash_fn = screen_flash
|
||||
return self
|
||||
|
||||
@@ -54,6 +56,8 @@ func propagate_insert_state() -> void:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
if economics_panel:
|
||||
economics_panel.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
@@ -173,6 +177,15 @@ func consume_debug_response() -> void:
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# #824: Forward economy_snapshot from server to the economics panel (D-181).
|
||||
func consume_economy_snapshot() -> void:
|
||||
if GameState.economy_snapshot == null or not economics_panel:
|
||||
return
|
||||
if economics_panel.has_method("receive_economy_data"):
|
||||
economics_panel.receive_economy_data(GameState.economy_snapshot)
|
||||
GameState.economy_snapshot = null
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
func consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
|
||||
@@ -213,6 +213,12 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
GameState.settings_response = null
|
||||
|
||||
# v21: economy_snapshot (#824, D-181)
|
||||
if snapshot.has("economy_snapshot") and snapshot.economy_snapshot is Dictionary:
|
||||
GameState.economy_snapshot = snapshot.economy_snapshot
|
||||
else:
|
||||
GameState.economy_snapshot = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
if (
|
||||
snapshot.has("character_visual_descriptor")
|
||||
|
||||
@@ -225,10 +225,178 @@ func _dispatch(line: String) -> void:
|
||||
_send_debug("ListPopulation")
|
||||
"status":
|
||||
_send_debug("GetContaminationStatus")
|
||||
"econ":
|
||||
_dispatch_econ(parts)
|
||||
_:
|
||||
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
|
||||
|
||||
|
||||
# #825: Economics debug console commands (D-178, D-180, D-181).
|
||||
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
|
||||
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
|
||||
# ship with #823. Until then, _send_debug will transmit the payload but the server will
|
||||
# respond with an "unknown command" error. Wire format is ready; server handler is not.
|
||||
func _dispatch_econ(parts: Array) -> void:
|
||||
if parts.size() < 2:
|
||||
_append_text(
|
||||
(
|
||||
"usage:\n"
|
||||
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
|
||||
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
|
||||
+ " econ inspect <system_id>"
|
||||
),
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
var sub := parts[1].to_lower()
|
||||
match sub:
|
||||
"inject":
|
||||
_econ_inject(parts)
|
||||
"param":
|
||||
_econ_param(parts)
|
||||
"inspect":
|
||||
_econ_inspect(parts)
|
||||
_:
|
||||
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
|
||||
|
||||
|
||||
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||||
# Minimal form: econ inject Sol shock 0.5
|
||||
# Full form: econ inject Sol fusion_fuel boost 1.2 100
|
||||
func _econ_inject(parts: Array) -> void:
|
||||
# parts[0]="econ", parts[1]="inject", rest is args
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 3:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
|
||||
var system_id: String = args[0]
|
||||
var commodity_id: String = ""
|
||||
var effect: String = ""
|
||||
var magnitude_str: String = ""
|
||||
var duration_ticks: int = 0
|
||||
|
||||
var ticks_str := ""
|
||||
if args[1].to_lower() in ["shock", "boost"]:
|
||||
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
|
||||
effect = args[1].to_lower()
|
||||
magnitude_str = args[2]
|
||||
if args.size() >= 4:
|
||||
ticks_str = args[3]
|
||||
elif args.size() == 3:
|
||||
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
|
||||
)
|
||||
return
|
||||
else:
|
||||
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
|
||||
commodity_id = args[1]
|
||||
if args.size() < 4:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
effect = args[2].to_lower()
|
||||
if not effect in ["shock", "boost"]:
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
magnitude_str = args[3]
|
||||
if args.size() >= 5:
|
||||
ticks_str = args[4]
|
||||
|
||||
if not ticks_str.is_empty():
|
||||
if not ticks_str.is_valid_int():
|
||||
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
|
||||
return
|
||||
duration_ticks = int(ticks_str)
|
||||
|
||||
if not magnitude_str.is_valid_float():
|
||||
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
|
||||
return
|
||||
var magnitude: float = float(magnitude_str)
|
||||
|
||||
var payload := {
|
||||
"system_id": system_id,
|
||||
"effect": effect,
|
||||
"magnitude": magnitude,
|
||||
}
|
||||
if not commodity_id.is_empty():
|
||||
payload["commodity_id"] = commodity_id
|
||||
if duration_ticks > 0:
|
||||
payload["duration_ticks"] = duration_ticks
|
||||
|
||||
_append_text(
|
||||
(
|
||||
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
|
||||
% [
|
||||
effect,
|
||||
system_id,
|
||||
" / " + commodity_id if not commodity_id.is_empty() else "",
|
||||
magnitude,
|
||||
duration_ticks
|
||||
]
|
||||
),
|
||||
TEXT_COLOR,
|
||||
)
|
||||
_send_debug({"InjectEconEvent": payload})
|
||||
|
||||
|
||||
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||||
func _econ_param(parts: Array) -> void:
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 2:
|
||||
_append_text(
|
||||
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
var param_name: String = args[0].to_lower()
|
||||
if not param_name in ["alpha", "beta", "friction"]:
|
||||
_append_text(
|
||||
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
|
||||
if not args[1].is_valid_float():
|
||||
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
|
||||
return
|
||||
var value: float = float(args[1])
|
||||
|
||||
var payload := {"param": param_name, "value": value}
|
||||
if args.size() >= 3:
|
||||
payload["system_a"] = args[2]
|
||||
if args.size() >= 4:
|
||||
payload["system_b"] = args[3]
|
||||
|
||||
var scope := "global"
|
||||
if payload.has("system_a") and payload.has("system_b"):
|
||||
scope = "%s ↔ %s" % [payload["system_a"], payload["system_b"]]
|
||||
elif payload.has("system_a"):
|
||||
scope = payload["system_a"]
|
||||
|
||||
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
|
||||
_send_debug({"SetEconParam": payload})
|
||||
|
||||
|
||||
# econ inspect <system_id> — request all 7 D-181 signals for a system
|
||||
func _econ_inspect(parts: Array) -> void:
|
||||
if parts.size() < 3:
|
||||
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
|
||||
return
|
||||
var system_id: String = parts[2]
|
||||
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
|
||||
_send_debug({"GetEconState": system_id})
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := (
|
||||
SimBridge
|
||||
@@ -284,6 +452,14 @@ func _print_help() -> void:
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ "\n"
|
||||
+ "Economics (D-178/D-180/D-181):\n"
|
||||
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
|
||||
+ " — fire an EconEvent at a system\n"
|
||||
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
|
||||
+ " — set tâtonnement parameter\n"
|
||||
+ " econ inspect <sys> — show all 7 signals for a system\n"
|
||||
+ "\n"
|
||||
+ " help — this list"
|
||||
),
|
||||
TEXT_COLOR
|
||||
|
||||
+7
-1
@@ -1,7 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
[ext_resource type="PackedScene" path="res://ui/implant/economics_panel.tscn" id="3_econ"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -19,3 +20,8 @@ script = ExtResource("1_hud")
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
; #824: Economics Monitor — implant/economics INSERT panel. Toggled via E key from main.gd.
|
||||
; Composes ImplantPanel from the D-169 component library. Placeholder data until #822 ships.
|
||||
[node name="EconomicsPanel" parent="." instance=ExtResource("3_econ")]
|
||||
visible = false
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name EconomicsPanel
|
||||
extends Control
|
||||
|
||||
## Economics Monitor — implant insert panel (#824, D-170, D-181).
|
||||
##
|
||||
## Displays price data and GDP for a selected system. Receives economy_snapshot
|
||||
## from the server via snapshot_handler → GameState → snapshot_consumers pipeline.
|
||||
##
|
||||
## Data architecture:
|
||||
## - Ring buffer: last 20 ticks of economy data per system (for trend display)
|
||||
## - 7 D-181 signals per system: price_current, price_trend, trade_flow_volume,
|
||||
## corporate_presence, stockpile_weeks, production_vs_baseline, official_coverage_ratio
|
||||
## - Signals 1-2 (price_current, price_trend) are Phase 2 deliverables
|
||||
## - Signals 3-7 are parsed and stored but not yet displayed (Phase 3)
|
||||
##
|
||||
## Visual layer: ImplantPanel composition built in _ready() from component library (D-169).
|
||||
## System selector uses LEFT/RIGHT arrow keys to cycle through all 301 systems.
|
||||
## Placeholder commodity prices shown until #822 ships.
|
||||
|
||||
## Emitted when new economy data arrives for the selected system.
|
||||
signal economy_data_updated(system_id: String, data: Dictionary)
|
||||
|
||||
const APP_PATH := "implant/economics"
|
||||
const RING_BUFFER_SIZE: int = 20
|
||||
const STAR_MAP_DATA := "res://data/star_map_data.json"
|
||||
const PANEL_WIDTH: float = 340.0
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
|
||||
# Placeholder commodity rows shown until server ships EconomySnapshot (#822).
|
||||
# Commodity IDs match D-184 catalog.
|
||||
const PLACEHOLDER_COMMODITIES: Array[Dictionary] = [
|
||||
{"id": "fusion_fuel", "name": "FUSION FUEL", "price": 142, "trend": 1},
|
||||
{"id": "basic_goods", "name": "BASIC GOODS", "price": 58, "trend": 0},
|
||||
{"id": "machinery", "name": "MACHINERY", "price": 890, "trend": -1},
|
||||
{"id": "organics", "name": "ORGANICS", "price": 34, "trend": 1},
|
||||
{"id": "lattice_comp", "name": "LATTICE COMP.", "price": 2240, "trend": 0},
|
||||
{"id": "pharmaceuticals", "name": "PHARMA", "price": 312, "trend": -1},
|
||||
]
|
||||
|
||||
## Currently selected system for detailed display. Empty = no selection.
|
||||
var selected_system: String = ""
|
||||
|
||||
## Ring buffer: system_id → Array[Dictionary] (most recent last, max RING_BUFFER_SIZE).
|
||||
## Each entry is one tick's worth of D-181 signals for that system.
|
||||
var _history: Dictionary = {}
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
# Visual panel state (D-169 component library)
|
||||
var _panel: ImplantPanel = null # root container
|
||||
var _implant_theme: ImplantTheme = null
|
||||
var _header: ImplantHeader = null # kept for set_content() on system change
|
||||
var _nav_row: ImplantDataRow = null # system selector nav hint
|
||||
var _gdp_row: ImplantDataRow = null # GDP value row
|
||||
var _commodity_rows: Array = [] # ImplantDataRow × 6, updated without full rebuild
|
||||
var _placeholder_notice: ImplantTextBlock = null # hidden once live data arrives
|
||||
|
||||
# System list for the selector (populated from STAR_MAP_DATA)
|
||||
var _systems: Array = [] # Array[Dictionary], sorted by proper_name
|
||||
var _selected_idx: int = 0 # index into _systems
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
# D-170: Register with HUD layer groups
|
||||
HudGroups.register(self, APP_PATH)
|
||||
HudGroups.app_changed.connect(_on_app_changed)
|
||||
|
||||
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
_load_system_list()
|
||||
_build_panel()
|
||||
economy_data_updated.connect(_on_economy_data_updated)
|
||||
|
||||
|
||||
## Called from SnapshotConsumers when economy_snapshot arrives in GameState.
|
||||
## data: Dictionary keyed by system_id → signal payload (D-181).
|
||||
func receive_economy_data(data: Dictionary) -> void:
|
||||
for system_id: String in data:
|
||||
var signals: Variant = data[system_id]
|
||||
if not signals is Dictionary:
|
||||
continue
|
||||
if not _history.has(system_id):
|
||||
_history[system_id] = []
|
||||
var buf: Array = _history[system_id]
|
||||
buf.append(signals)
|
||||
if buf.size() > RING_BUFFER_SIZE:
|
||||
_history[system_id] = buf.slice(buf.size() - RING_BUFFER_SIZE)
|
||||
|
||||
# Notify listeners if the selected system received new data
|
||||
if not selected_system.is_empty() and data.has(selected_system):
|
||||
economy_data_updated.emit(selected_system, data[selected_system])
|
||||
|
||||
|
||||
## Select a system for detailed display. Emits economy_data_updated if history exists.
|
||||
func select_system(system_id: String) -> void:
|
||||
selected_system = system_id
|
||||
if not selected_system.is_empty() and _history.has(selected_system):
|
||||
var buf: Array = _history[selected_system]
|
||||
if buf.size() > 0:
|
||||
economy_data_updated.emit(selected_system, buf[buf.size() - 1])
|
||||
|
||||
|
||||
## Get the full ring buffer for a system (for chart/sparkline rendering).
|
||||
## Returns empty array if no history exists.
|
||||
func get_history(system_id: String) -> Array:
|
||||
return _history.get(system_id, [])
|
||||
|
||||
|
||||
## Get the latest tick's signals for a system, or empty dict.
|
||||
func get_latest(system_id: String) -> Dictionary:
|
||||
var buf: Array = _history.get(system_id, [])
|
||||
if buf.size() > 0:
|
||||
return buf[buf.size() - 1]
|
||||
return {}
|
||||
|
||||
|
||||
## Get all system IDs that have received at least one tick of data.
|
||||
func get_known_systems() -> Array:
|
||||
return _history.keys()
|
||||
|
||||
|
||||
## Toggle via HUD layer system (D-170). INSERT mode — shares screen with gameplay.
|
||||
func toggle_visible() -> void:
|
||||
HudGroups.toggle_app(APP_PATH, HudGroups.Mode.INSERT)
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes (D-170).
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active and HudGroups.is_app_active(APP_PATH):
|
||||
HudGroups.close_app()
|
||||
|
||||
|
||||
## Respond to app layer changes (D-170).
|
||||
func _on_app_changed(app_path: String, mode: int) -> void:
|
||||
if app_path != APP_PATH:
|
||||
return
|
||||
if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT:
|
||||
visible = true
|
||||
else:
|
||||
visible = false
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System list — populated from star_map_data.json
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_system_list() -> void:
|
||||
if not FileAccess.file_exists(STAR_MAP_DATA):
|
||||
push_warning("EconomicsPanel: %s not found" % STAR_MAP_DATA)
|
||||
return
|
||||
var file := FileAccess.open(STAR_MAP_DATA, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
for node: Dictionary in parsed.get("nodes", []):
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not sid.is_empty():
|
||||
_systems.append(node)
|
||||
_systems.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var na: String = a.get("proper_name", a.get("system_id", ""))
|
||||
var nb: String = b.get("proper_name", b.get("system_id", ""))
|
||||
return na < nb
|
||||
)
|
||||
if not _systems.is_empty():
|
||||
selected_system = _systems[0].get("system_id", "")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Visual panel — D-169 ImplantPanel composition
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_panel() -> void:
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "EconPanel"
|
||||
_panel.theme_resource = _implant_theme
|
||||
_panel.custom_minimum_size.x = PANEL_WIDTH
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN)
|
||||
add_child(_panel)
|
||||
_rebuild_panel()
|
||||
|
||||
|
||||
## Full rebuild of panel components. Called on system change and initial build.
|
||||
func _rebuild_panel() -> void:
|
||||
if not _panel:
|
||||
return
|
||||
_panel.clear()
|
||||
_commodity_rows.clear()
|
||||
|
||||
var node: Dictionary = _current_node()
|
||||
var sys_name: String = node.get("proper_name", node.get("system_id", "—"))
|
||||
var sys_id: String = node.get("system_id", "")
|
||||
var total: int = _systems.size()
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────────
|
||||
_header = ImplantHeader.new("ECONOMICS MONITOR", sys_name)
|
||||
_panel.add_component(_header)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── System selector nav ────────────────────────────────────────────────────
|
||||
var nav_hint := "◄ ► · %s [%d / %d]" % [sys_id, _selected_idx + 1, total]
|
||||
_nav_row = ImplantDataRow.new(nav_hint)
|
||||
_panel.add_component(_nav_row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── GDP strip ─────────────────────────────────────────────────────────────
|
||||
var gdp_str: String = node.get("gdp", "—")
|
||||
_gdp_row = ImplantDataRow.new("gdp " + gdp_str)
|
||||
_panel.add_component(_gdp_row)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# ── Price table ───────────────────────────────────────────────────────────
|
||||
_panel.add_component(ImplantTextBlock.new("MARKET PRICES"))
|
||||
|
||||
var latest: Dictionary = get_latest(sys_id)
|
||||
var commodity_signals: Array = latest.get("price_current", [])
|
||||
|
||||
for c: Dictionary in PLACEHOLDER_COMMODITIES:
|
||||
var cid: String = c.get("id", "")
|
||||
var price: int = c.get("price", 0)
|
||||
var trend: int = c.get("trend", 0)
|
||||
|
||||
# Overlay live data when available (D-181 signal 1-2)
|
||||
for sig: Dictionary in commodity_signals:
|
||||
if sig.get("commodity_id", "") == cid:
|
||||
price = int(sig.get("price_current", price))
|
||||
trend = int(sig.get("price_trend", trend))
|
||||
break
|
||||
|
||||
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
|
||||
var row := ImplantDataRow.new(row_text)
|
||||
_panel.add_component(row)
|
||||
_commodity_rows.append(row)
|
||||
|
||||
# ── Placeholder notice ────────────────────────────────────────────────────
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
var notice_text: String = (
|
||||
"[LIVE MARKET — #822 PENDING]" if _history.is_empty() else "LIVE DATA ACTIVE"
|
||||
)
|
||||
_placeholder_notice = ImplantTextBlock.new(notice_text)
|
||||
_panel.add_component(_placeholder_notice)
|
||||
|
||||
_panel.add_component(ImplantSeparator.new())
|
||||
_panel.add_component(ImplantTextBlock.new("[ ] select system · N close"))
|
||||
|
||||
|
||||
func _current_node() -> Dictionary:
|
||||
if _systems.is_empty():
|
||||
return {}
|
||||
_selected_idx = clampi(_selected_idx, 0, _systems.size() - 1)
|
||||
return _systems[_selected_idx]
|
||||
|
||||
|
||||
func _trend_glyph(trend: int) -> String:
|
||||
if trend > 0:
|
||||
return "▲"
|
||||
if trend < 0:
|
||||
return "▼"
|
||||
return "—"
|
||||
|
||||
|
||||
## Respond to economy_data_updated signal — refresh the price table in-place.
|
||||
func _on_economy_data_updated(system_id: String, data: Dictionary) -> void:
|
||||
if not _panel or _commodity_rows.is_empty():
|
||||
return
|
||||
if system_id != selected_system:
|
||||
return
|
||||
|
||||
var commodity_signals: Array = data.get("price_current", [])
|
||||
|
||||
for i: int in range(PLACEHOLDER_COMMODITIES.size()):
|
||||
if i >= _commodity_rows.size():
|
||||
break
|
||||
var c: Dictionary = PLACEHOLDER_COMMODITIES[i]
|
||||
var cid: String = c.get("id", "")
|
||||
var price: int = c.get("price", 0)
|
||||
var trend: int = c.get("trend", 0)
|
||||
|
||||
for sig: Dictionary in commodity_signals:
|
||||
if sig.get("commodity_id", "") == cid:
|
||||
price = int(sig.get("price_current", price))
|
||||
trend = int(sig.get("price_trend", trend))
|
||||
break
|
||||
|
||||
var row_text: String = "%-14s %5d %s" % [c["name"], price, _trend_glyph(trend)]
|
||||
_commodity_rows[i].text = row_text
|
||||
|
||||
if _placeholder_notice and not _history.is_empty():
|
||||
_placeholder_notice.text = "LIVE DATA ACTIVE"
|
||||
|
||||
|
||||
## Cycle the system selector by delta steps (+1 or -1).
|
||||
## Called from main.gd _unhandled_key_input — [ and ] keys when panel is active.
|
||||
func navigate(delta: int) -> void:
|
||||
if _systems.is_empty():
|
||||
return
|
||||
_selected_idx = wrapi(_selected_idx + delta, 0, _systems.size())
|
||||
selected_system = _systems[_selected_idx].get("system_id", "")
|
||||
_rebuild_panel()
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/implant/economics_panel.gd" id="1_econ"]
|
||||
|
||||
; #824: Economics Monitor insert panel — price data and GDP for selected system.
|
||||
; Composed from ImplantPanel component library (D-169). Registered under implant/economics (D-170).
|
||||
; Toggle with E key in implant mode. Data flows from EconomySnapshot via snapshot_consumers.
|
||||
; Placeholder commodity prices shown until server ticket #822 ships.
|
||||
|
||||
[node name="EconomicsPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_econ")
|
||||
@@ -135,7 +135,6 @@ func _process(_delta: float) -> void:
|
||||
_dirty = false
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes.
|
||||
## Called from main.gd when insert state changes.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
@@ -507,10 +506,13 @@ func _rebuild_info_panel() -> void:
|
||||
|
||||
# Population + GDP
|
||||
var population: String = node.get("population", "")
|
||||
if not population.is_empty():
|
||||
var gdp: String = node.get("gdp", "")
|
||||
if not population.is_empty() or not gdp.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
_info_panel.add_component(ImplantDataRow.new("GDP —"))
|
||||
if not population.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
var gdp_label: String = "gdp " + (gdp if not gdp.is_empty() else "—")
|
||||
_info_panel.add_component(ImplantDataRow.new(gdp_label))
|
||||
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|
||||
|
||||
| File | Domain | Decisions |
|
||||
|------|--------|-----------|
|
||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152 |
|
||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-191 |
|
||||
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
|
||||
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107, D-121, D-122, D-123, D-124, D-125, D-126, D-127, D-128, D-129, D-130, D-131, D-132, D-138, D-139, D-140, D-142, D-147 |
|
||||
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091, D-114, D-115, D-116, D-117, D-118, D-119, D-120, D-145, D-146, D-153, D-154, D-155, D-156, D-157 |
|
||||
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187 |
|
||||
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187, D-189, D-190 |
|
||||
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
||||
| [questions.md](questions.md) | Open questions (index) | Q-001 through Q-094 |
|
||||
| [questions-architecture.md](questions-architecture.md) | Technical questions | Q-001, Q-006, Q-009, Q-018–Q-023, Q-029, Q-030, Q-046, Q-059, Q-060, Q-063–Q-094 |
|
||||
|
||||
@@ -654,4 +654,87 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*51 decisions. Last updated: 2026-04-06 (D-188 biome_summary → planet_class rename)*
|
||||
### D-191: Atlas of the Reach — Phase 3 Scope and Pipeline
|
||||
- **Date:** 2026-04-10
|
||||
- **Decision:** Phase 3 delivers the Atlas of the Reach as an extension of the implant map (`implant/map`), adding planetary and regional zoom levels to the existing star map. The Atlas is a read-only spatial intelligence tool — the player looks at it to plan, not to execute actions. It serves dual purpose: gameplay information layer and world-texture content system.
|
||||
|
||||
**1. Zoom Hierarchy**
|
||||
- 4 levels: Reach map (Phase 1, exists) → System view (orbital diagram, new) → Planetary view (hemisphere, new) → Regional view (hundreds-of-km, new — core Phase 3 deliverable)
|
||||
- Atlas is the star map extended downward, not a separate app. `implant/map` at different zoom levels.
|
||||
- Station maps and underground/cave city maps are DEFERRED to a later sprint (similar bounded generation pattern).
|
||||
|
||||
**2. Existing Foundation**
|
||||
- 2,394 heightmap PNGs (1024×512 equirectangular, production quality)
|
||||
- 2,394 markers.json files with procedural geometry (rivers, oceans, mountains) — all names null, all cities/roads/rail/POIs empty
|
||||
- Planet-gen pipeline (`tooling/planet-gen/`) explicitly designed for Phase 3: `render_heightmap.py` line 26-27 defers cultural overlay to the atlas app
|
||||
- 301 systems in systems.db, 273 inhabited bodies, 466 stations, 668 gate links
|
||||
|
||||
**3. Content Pipeline — Sequential Settlement Growth Simulation**
|
||||
- City placement is terrain-aware and sequential (not scatter)
|
||||
- Capital first: favor river mouths (~50% of capitals), scored by habitability (temperature, moisture, slope), coastal access, flat hinterland
|
||||
- Subsequent cities: grow along rail corridors from capital (multi-source Dijkstra on cost grid)
|
||||
- At cities 3–4: first foothold on a new continent if one exists (port city)
|
||||
- Roads and rail: A* pathfinding on terrain cost grid (water=impassable, mountains=expensive, rivers=cheap corridors), minimum spanning tree — NO straight lines
|
||||
- Quadrant distribution: after 2 cities in the same map quadrant, subsequent cities must prefer unoccupied quadrants unless those quadrants have no habitable land
|
||||
- Variation: ±25% noise on scoring per seed — same terrain, different seed → different city network
|
||||
- Settlement pattern modifies spacing (`urban_concentrated`=tight, `dispersed`=wide)
|
||||
- All systems same depth, scaled by population. No manual tier classification.
|
||||
|
||||
**4. Naming Pipeline — Gemma 2 Voice Pipeline**
|
||||
- Geographic names generated by the existing Gemma 2 voice pipeline (`server/src/voice/`, `sr-voice` binary)
|
||||
- Input: body wiki page (`planet_class`, `cultural_corridor`, `settlement_pattern`, population, economic_role, atmospheric_tone) + corridor naming palette
|
||||
- Output: culturally-appropriate names for rivers, oceans, mountains, regions, cities
|
||||
- Corridor palettes: north_reach (Anglo-Saxon), south_reach (Iberian/Portuguese), east_reach (East Asian), west_reach (Germanic/Nordic), inner_orbit (institutional Latin/Anglo)
|
||||
- Dual purpose: Phase 3 content generation AND quality/consistency test of the in-game LLM pipeline
|
||||
- Batch throughput: ~80 min for all 2,394 bodies (placement) + ~40 min for 273 inhabited (naming). Parallelizable.
|
||||
- Earth-name blocklist as post-processing safety net. Dedup check against full name corpus.
|
||||
|
||||
**5. Core Systems as Templates**
|
||||
- Core systems (Gateway/Sirius, Groombridge/Lendel, etc.) hand-authored as templates
|
||||
- Templates establish rulesets and quality bar for the generator
|
||||
- Generator produces all remaining bodies → hand-author refinements over the batch
|
||||
- Lendel (GJ 380c) is the first proving ground: arid, `urban_concentrated`, financial hub, 900M pop
|
||||
|
||||
**6. Atlas Panel Architecture**
|
||||
- FULLSCREEN implant app (z=20), same component library as economics panel
|
||||
- 3 navigation levels: system picker → orbital diagram → body atlas (heightmap + overlays)
|
||||
- Heightmap displayed as `Texture2D` with pan/zoom
|
||||
- Marker overlay renders cities, roads, rail, POIs, named features on top of heightmap
|
||||
- Click city → City Data Panel (name, population, currency zone, Commission presence, shadow economy zone, gate distance, economics panel link)
|
||||
- Economics panel link: click-through to Phase 2 economics panel pre-filtered to that node — primary Phase 2/3 integration point
|
||||
|
||||
**7. Overlay System — 9 MVP Overlays**
|
||||
- 5 always-on: terrain, infrastructure, named features, gate/spaceport POIs, political zones
|
||||
- 4 toggleable: population density, production zones, shadow economy zones (broad bands), corporate presence (Tier 1 only)
|
||||
- Deferred overlays visible in toggle bar but locked with unlock requirements shown on hover (creates pull toward Phase 4+ systems)
|
||||
- Maps to D-181 signal visibility ladder
|
||||
|
||||
**8. Settlement Data Model**
|
||||
- markers.json schema per body: `cities` (name, lat/lon, population_tier, primary_function, gate_terminal, continent_id), `roads` (path polylines, connects), `railroads` (path polylines, connects), `pois` (name, kind, position), plus existing rivers/oceans/mountains with names filled
|
||||
- Population tier → city count: `floor(log10(pop/1M))`, modified by `settlement_pattern`
|
||||
- Gate terminal POI: at largest population center, sometimes scattered to a smaller one
|
||||
- Moons: same depth as planets, scale with population
|
||||
|
||||
**9. Implementation Pipeline**
|
||||
- Python: `tooling/planet-gen/generate_atlas.py` — reuses `planet_simulation.simulate()`, adds `city_placement.py`, `infrastructure_gen.py`, `gemma_naming.py`, `markers_writer.py`
|
||||
- `make atlas-generate` runs the full batch. Deterministic per seed. Incremental (skips up-to-date bodies).
|
||||
- Pipeline order: simulate terrain → analyze (continents, habitability, river mouths, cost grid) → place cities sequentially → generate infrastructure (A* MST) → name via Gemma 2 → write markers.json
|
||||
|
||||
**10. MVP Completion Criteria**
|
||||
1. Navigation chain works end-to-end (Reach → system → planet → regional)
|
||||
2. Regional map content complete for all inhabited bodies
|
||||
3. Population-scaled depth (core systems rich, frontier sparse)
|
||||
4. City data panel works (click any city, see data + economics link)
|
||||
5. Economics panel integration works (link opens Phase 2 panel filtered to node)
|
||||
6. All 9 MVP overlays present and functional
|
||||
7. Atlas is read-only (no verbs execute from map)
|
||||
8. Stations show at system view with mini data panel (no drill-down)
|
||||
|
||||
- **Rationale:** The heightmap pipeline was designed with Phase 3 in mind — 2,394 base maps exist. The critical path is content (filling empty markers.json arrays), not technology (the atlas panel follows the established implant component pattern). Sequential settlement growth simulation produces more realistic city networks than scatter placement — each city's location is informed by previous placements, terrain, and economic logic. Using the Gemma 2 voice pipeline for naming tests the in-game LLM quality while generating content. Population-scaled depth without manual tier classification keeps the pipeline simple and the authoring burden manageable.
|
||||
- **Raised by:** Full planning team workshop, Sprint 34 (#748). Participants: Gestalt (systems design), Tyre (technical architecture), Miri (worldbuilding), with Jeroen as workshop participant.
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** [D-166](architecture.md#d-166) (development cascade — Phase 3), [D-036](content.md#d-036) (Sova as canonical setting), [D-093](content.md#d-093-sova-transit-district--spatial-layout-and-district-topology) (Sova spatial layout), [D-094](#d-094) (district hierarchy), [D-095](content.md#d-095) (Horizon stations), [D-170](#d-170) (HUD visibility/implant apps), [D-169](#d-169) (implant component library), [D-181](economics.md#d-181-signal-vocabulary) (signal vocabulary/visibility ladder), [D-174](economics.md#d-174-shadow-economy-layer) (shadow economy intensity), [D-175](economics.md#d-175-corporation-taxonomy-and-prerequisite) (corporation taxonomy), [D-138](content.md#d-138-llm-re-voicing-pipeline-for-npc-voice) (Gemma 2 voice pipeline)
|
||||
|
||||
---
|
||||
|
||||
*53 decisions. Last updated: 2026-04-10 (D-191 Atlas of the Reach — Phase 3 scope and pipeline)*
|
||||
|
||||
+137
-1
@@ -304,4 +304,140 @@ This domain covers: currency system, commodity taxonomy, shadow economy, corpora
|
||||
|
||||
---
|
||||
|
||||
*17 decisions (D-171–D-187), 1 rejected alternative (R-011). Domain: economics. Last updated: 2026-04-05.*
|
||||
### D-189: Brand Layer Architecture
|
||||
- **Date:** 2026-04-10
|
||||
- **Decision:** The simulation supports a brand layer above the commodity tâtonnement. Brands are NOT commodities (D-185). They consume commodities as demand nodes and are priced through an administered pricing model with cultural premium curves. The brand layer serves dual purposes: economic simulation (demand nodes, pricing, GDP contribution) and queryable localized content for client UI (bar shelves, restaurant menus, shop displays, entertainment listings).
|
||||
|
||||
**1. Brand Taxonomy**
|
||||
- 8 categories: `terroir`, `heritage_craft`, `tech_premium`, `cultural`, `service_premium`, `commodity_branded`, `design_heritage`, `platform_catalogue`
|
||||
- `value_trajectory`: `appreciating` | `depreciating` | `timeless` (first-class field)
|
||||
- 3 scale tiers for generated brands: local (1–3 systems), regional (corridor-scale), reach-wide budget (everywhere, corridor-neutral naming, no cultural premium)
|
||||
|
||||
**2. Pricing Model**
|
||||
```
|
||||
brand_price = max(price_floor, [base_cost × (1 + target_margin) + cultural_premium × (1 + veblen × scarcity)] × value_trajectory_factor × currency_factor)
|
||||
```
|
||||
- Cultural premium split into `identity_term` + `exotic_term` with `exotic_floor` to support 3 curve types: Scarcity-Distance (artisan), Dual-Peak (media/content), Aspirational Gradient (tech)
|
||||
- Two-component scarcity: structural (`production_volume / addressable_demand` ratio, permanent) + situational (stockpile depletion, temporary)
|
||||
- Veblen per-location derived: `veblen_base × income_quintile × corridor_affinity` — zero authoring cost
|
||||
- `cost_passthrough_ratio`: insulates brand pricing from tâtonnement volatility (low 0.10–0.25 for terroir, high 0.50–0.75 for tech)
|
||||
- `value_trajectory_factor`: appreciating goods gain value with vintage age, depreciating goods lose value with a floor, timeless = 1.0; updated per game-year
|
||||
|
||||
**3. Halo/Volume Tier Structure**
|
||||
- Universal pattern: every notable brand has a halo product (defines identity ceiling) + volume tier(s) (makes the brand economically relevant at population scale ~80B)
|
||||
- `halo_lift_factor`: volume tier borrows a fraction of the halo's cultural premium
|
||||
- Direction inverts by category: terroir pushes scarcity up, tech/media pushes quality up from a mass base
|
||||
- Brands without volume tiers are economically marginal regardless of prestige — population asymmetry (10B systems vs. 50k) makes this structurally necessary
|
||||
|
||||
**4. Brand Census**
|
||||
- Notable (hand-authored): 120–170 corps with TOML records; ~27 currently named
|
||||
- Minor (template-generated): ~10,000 brands from ~120–130 template definitions (35–40 archetypes × 3 sub-variants), corridor-specific naming patterns, 3 scale tiers
|
||||
- Naming patterns: corridor-appropriate — north_reach = British/Australian inflection, east_reach = Korean/Japanese, west_reach = German/Dutch/Nordic, south_reach = Portuguese/Swahili, inner_corridor = pan-corridor neutral, frontier = founder surname + noun
|
||||
- Queryable content: `brand_products JOIN corp_presence` filtered by location and `product_subcategory`; sub-millisecond at 10K+ rows — primary use case alongside economic simulation
|
||||
|
||||
**5. DB Schema**
|
||||
- `brand_products`: `brand_product_id`, `corp_id`, `product_name`, `brand_category` (8 values), `value_trajectory`, `scarcity_class` (`capped` / `constrained` / `scalable` / `unlimited`), `product_subcategory`, `base_premium_multiplier`, `premium_floor`, `origin_system`, `terroir_locked`, `currency_denomination`, `shadow_viable`, `brand_tier` (`halo` / `volume`), `halo_brand_id` (for volume tiers)
|
||||
- `brand_inputs`: `brand_product_id`, `commodity_id`, `quantity`
|
||||
- `system_fiscal`: `system_id`, `corp_tax_rate`, `collection_efficiency` (derived from `shadow_economy_intensity`)
|
||||
- `corp_financial_state` + `corp_lifecycle_events` tables for acquisition/startup lifecycle
|
||||
- Composite index on `brand_products(corp_id, brand_category)` for UI queries
|
||||
|
||||
**6. Corp Tax & GDP**
|
||||
- Corp tax = `revenue × (1 - category_deduction) × tax_rate × collection_efficiency`
|
||||
- Category deductions: terroir 0.30, geological 0.20, tech 0.60, media 0.15, vehicles 0.55, apparel 0.45
|
||||
- Tax flows to HQ system; GDP computed every 100 ticks
|
||||
- `collection_efficiency = 1.0 - shadow_economy_intensity × 0.6`
|
||||
- Visible line item when player owns a company
|
||||
|
||||
**7. Player Verb Ladder**
|
||||
- 6 stages: Operate → Specialize → Distribute → Create → Scale → Corporate
|
||||
- Founding verbs: `brand`, `register`, `market`
|
||||
- Content verbs: `acquire-rights`, `royalty-contract`, `exclusive-window`
|
||||
- Corporate verbs: `acquire`, `merge`, `spin-off`, `license-out`
|
||||
- Temporal verbs: `cellar` (appreciating), `refresh` (depreciating), `license-legacy` (EOL)
|
||||
- `authenticate` is the mechanically richest new verb — 3 service economies: expert appraisal (artisan), Commission inspection (tech/vehicles), Meridian rights lookup (content)
|
||||
|
||||
**8. Acquisition & Startup Lifecycle**
|
||||
- Corp lifecycle states: Founded → Growing → Active → Distressed → Acquired/Dissolved
|
||||
- Startup triggers: market gap, spin-off, player-founded, storyteller event
|
||||
- Acquisition triggers: financial distress, strategic AI acquisition, player-initiated, hostile takeover event
|
||||
- Player acquisition and mergers are in scope; if acquisition exists, startups must exist (or the well dries)
|
||||
- Phase 2: corp health tracking as passive metric. Phase 3: lifecycle state machine, startup generation, player acquisition
|
||||
|
||||
**9. Events**
|
||||
- `BrandPrestigeShock` via EconEvent port (D-180) — very occasional scandal events (pollution, fraud)
|
||||
- Exponential decay with authored half-life
|
||||
- Direct-vector only: events hit the brand's known vectors (commodity input price, production location); no indirect cascading beyond that
|
||||
|
||||
**10. Phase 2 Boundary**
|
||||
- Phase 2 (this sprint cycle): brand corps as commodity demand stubs in tâtonnement, `brand_products` / `brand_inputs` / `system_fiscal` schema, `corp_financial_state` passive tracking, `generate_brands` pipeline
|
||||
- Brand layer (post-Phase 2): pricing engine, awareness propagation, cultural preference curves, aging pipeline simulation, lifecycle state machine, player acquisition verbs
|
||||
|
||||
**11. Named Brand Corps (~27)**
|
||||
| Corp | Category | Origin |
|
||||
|------|----------|--------|
|
||||
| Calloway Distillery | terroir | north_reach |
|
||||
| VGV | terroir | west_reach |
|
||||
| thrds | heritage_craft | north_reach |
|
||||
| Bífröst Marmor | terroir | north_reach / Compact |
|
||||
| Destilaria Confluência / Lento | terroir | south_reach |
|
||||
| Veldfontein Botanical | terroir + heritage_craft | south_reach |
|
||||
| Comptoir Lendel | terroir + service_premium | inner_core |
|
||||
| Maison Cinq | design_heritage | inner_core / Gateway |
|
||||
| MVG (Manifattura Veicoli Gherardi) | design_heritage | west_reach Italian |
|
||||
| Higashiyama Vehicle Engineering | tech_premium + design_heritage | east_reach |
|
||||
| Rijdbaar Personal Mobility | design_heritage | west_reach / Compact |
|
||||
| Byeolbit Entertainment | cultural + platform_catalogue | east_reach |
|
||||
| Leerfeld Records | cultural | west_reach / Compact |
|
||||
| Vuma Sound | cultural | south_reach |
|
||||
| Resonance Premium | platform_catalogue | inner_core |
|
||||
| Dalbit Systems | tech_premium + platform_catalogue | east_reach |
|
||||
| Arclamp | cultural + design_heritage | inner_core |
|
||||
| Kellervolk | cultural | Compact |
|
||||
| Hangang Studio | platform_catalogue | east_reach |
|
||||
| Hanyang Precision | — (Tier 1, branded_products) | — |
|
||||
| Sato Medical | — (Tier 1, branded_products) | — |
|
||||
| Takamori Lattice | — (Tier 1, branded_products) | — |
|
||||
| Thalassa Resort | — (Tier 1, branded_products) | — |
|
||||
| Somatic Futures | — (Tier 1, branded_products) | — |
|
||||
| The Registry | — (Tier 1, branded_products) | — |
|
||||
| Meridian Risk | — (Tier 1, branded_products) | — |
|
||||
|
||||
- **Rationale:** Administered pricing is the correct model because brands violate all three tâtonnement assumptions: heterogeneity (Calloway ≠ VGV ≠ generic spirits), supply inelasticity (terroir production cannot respond to price signals per D-177), and Veblen demand effects (prestige goods can have upward-sloping demand). The one-way interface (commodity prices → brand input costs; brand output prices do NOT feed back into tâtonnement) is architecturally clean and matches the D-178 layer model. The identity/exotic split in cultural premium is the minimal structural addition needed to produce all three observed pricing curves (Scarcity-Distance, Dual-Peak, Aspirational Gradient). Population asymmetry (~80B total Reach population, systems ranging from 10B to <50k) makes the halo/volume tier pattern structurally necessary — brands from tiny worlds are astronomically exclusive and need volume derivatives to be economically relevant.
|
||||
- **Raised by:** Full planning team workshop, Sprint 34 (#811).
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** [D-185](#d-185-brands-are-not-commodities) (brands are not commodities), [D-184](#d-184-commodity-catalog-36-types) (commodity catalog), [D-177](#d-177-productivity-constraints-lore-derived) (productivity constraints), [D-175](#d-175-corporation-taxonomy-and-prerequisite) (corporation taxonomy), [D-178](#d-178-economic-model-architecture) (economic model architecture), [D-180](#d-180-event-input-port) (event input port), [D-181](#d-181-signal-vocabulary) (signal vocabulary), [D-173](#d-173-commodity-taxonomy) (commodity taxonomy), [D-171](#d-171-three-currency-system) (three-currency system), [D-131](content.md#d-131-broad-economic-verb-vocabulary--life-verbs-not-tycoon-specific) (economic verb vocabulary), [D-118](scope.md#d-118-small-business-owner-starting-state--tycoon-is-aspiration-not-starting-position) (small business owner starting state)
|
||||
|
||||
---
|
||||
|
||||
### D-190: Brand Volume Calibration — Population-Relative Scale
|
||||
- **Date:** 2026-04-10
|
||||
- **Decision:** All brand production and distribution volume numbers must be specified relative to a reference population, not as absolute counts. Volume without a reference population is not meaningful at Reach scale:
|
||||
- **Scale reference table (~80B Reach):**
|
||||
- Single-system local phenomenon: 100M–500M (1–5% of a 10B system)
|
||||
- Corridor-known hit: 250M–1B (~0.5% of corridor addressable population)
|
||||
- Reach-wide genuine hit: ~1B (1 in 80 Reach population)
|
||||
- All-time Reach canonical: 2B–5B (1 in 16–40 Reach population)
|
||||
- **Earth rule of thumb:** multiply Earth-scale phenomenon volumes by 12–15× for comparable cultural penetration. A 100M-seller on Earth ≈ 1.2–1.5B in the Reach.
|
||||
- **40M benchmark:** 40M units/streams Reach-wide = 0.04% penetration — a cult hit or successful regional release, not a cultural touchstone. 40M within a single large system (10B population) = 0.4% — respectable but not legendary.
|
||||
- **Authoring rule:** wiki volume figures for media brands and any good with `brand_category = cultural` must include a `reference_population` annotation alongside the count. "40M" is incomplete; "40M (west_reach corridor, ~10B addressable)" is correct. This applies to corporation production ceilings, media distribution figures, and market penetration estimates in wiki pages and TOML files.
|
||||
- **Structural scarcity principle:** for physical brand goods, production volume only has meaning relative to addressable demand. The `structural_scarcity_base` parameter in the brand pricing layer (D-189) is derived from this ratio: `1.0 - min(1.0, annual_volume / (addressable_population × demand_rate))`. A 12,000-unit/year artisan product against 100M addressable consumers yields structural_scarcity_base ≈ 0.88 — perpetually near-maximum scarcity regardless of local stockpile state. This scarcity floor is permanent, not situational.
|
||||
- **Rationale:** The Reach's population asymmetry (core systems 10B+, frontier systems under 50K) makes absolute volume numbers meaningless without a reference population. Without an explicit calibration rule, brand and media content significance will be systematically miscalibrated across all authoring. The structural scarcity principle connects volume calibration to the administered pricing model: a brand's Veblen premium floor is derived from the same population-relative ratio, ensuring that pricing and authoring are grounded in the same underlying reality.
|
||||
- **Raised by:** Jeroen (population asymmetry insight), Burnelli-Sheldon (structural scarcity derivation and calibration table), Sprint 34 Workshop #811.
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** [D-189](#d-189-brand-layer-architecture) (structural_scarcity_base parameter), [D-177](#d-177-productivity-constraints-lore-derived) (lore-constrained production ceilings), [D-175](#d-175-corporation-taxonomy-and-prerequisite) (corporation production volumes)
|
||||
|
||||
---
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
### R-011: Single currency for Phase 2 (rejected)
|
||||
- **Date:** 2026-04-05
|
||||
- **Proposed by:** Burnelli-Sheldon (economist), Sprint 32 Workshop #796 Round 1
|
||||
- **Proposal:** Use a single currency for the Phase 2 economics simulation to reduce model complexity. Exchange rate mechanics could be added in a later phase.
|
||||
- **Rejected because:** Three currencies create structural economic bloc tension as an emergent property of initialization — no event generation required. The Tractus/Mark divide maps directly to the Assembly vs. Compact political divide that is already canonical lore. Deferring currencies to a later phase would require retrofitting political geography into an already-running simulation. The complexity cost of three currencies is low; the design value is high.
|
||||
- **Raised by:** Lead directive overruling the recommendation.
|
||||
|
||||
---
|
||||
|
||||
*19 decisions (D-171–D-187, D-189–D-190), 1 rejected alternative (R-011). Domain: economics. Last updated: 2026-04-10.*
|
||||
|
||||
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
|
||||
```
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.33
|
||||
version: 0.1.34
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
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",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -23,6 +23,8 @@ sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
toml = "0.8"
|
||||
# Economics simulation — Leontief + tâtonnement + D-180 event port (#821)
|
||||
econ-sim = { path = "../tooling/econ-sim" }
|
||||
|
||||
[features]
|
||||
default = ["gauntlet"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+136
-1
@@ -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"):
|
||||
|
||||
@@ -6,293 +6,263 @@
|
||||
"rivers": [
|
||||
{
|
||||
"id": "river_0",
|
||||
"name": null,
|
||||
"name": "The Aldren",
|
||||
"path": [
|
||||
[
|
||||
172,
|
||||
276
|
||||
],
|
||||
[
|
||||
173,
|
||||
276
|
||||
],
|
||||
[
|
||||
174,
|
||||
275
|
||||
],
|
||||
[
|
||||
175,
|
||||
276
|
||||
],
|
||||
[
|
||||
176,
|
||||
275
|
||||
],
|
||||
[
|
||||
177,
|
||||
274
|
||||
],
|
||||
[
|
||||
178,
|
||||
273
|
||||
],
|
||||
[
|
||||
179,
|
||||
272
|
||||
],
|
||||
[
|
||||
180,
|
||||
271
|
||||
],
|
||||
[
|
||||
181,
|
||||
270
|
||||
]
|
||||
[172, 276],
|
||||
[173, 276],
|
||||
[174, 275],
|
||||
[175, 276],
|
||||
[176, 275],
|
||||
[177, 274],
|
||||
[178, 273],
|
||||
[179, 272],
|
||||
[180, 271],
|
||||
[181, 270]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "river_1",
|
||||
"name": null,
|
||||
"name": "The Kesset",
|
||||
"path": [
|
||||
[
|
||||
250,
|
||||
324
|
||||
],
|
||||
[
|
||||
249,
|
||||
324
|
||||
],
|
||||
[
|
||||
248,
|
||||
323
|
||||
],
|
||||
[
|
||||
247,
|
||||
322
|
||||
],
|
||||
[
|
||||
246,
|
||||
321
|
||||
],
|
||||
[
|
||||
245,
|
||||
322
|
||||
],
|
||||
[
|
||||
244,
|
||||
323
|
||||
],
|
||||
[
|
||||
243,
|
||||
323
|
||||
],
|
||||
[
|
||||
242,
|
||||
324
|
||||
],
|
||||
[
|
||||
241,
|
||||
325
|
||||
],
|
||||
[
|
||||
240,
|
||||
326
|
||||
]
|
||||
[250, 324],
|
||||
[249, 324],
|
||||
[248, 323],
|
||||
[247, 322],
|
||||
[246, 321],
|
||||
[245, 322],
|
||||
[244, 323],
|
||||
[243, 323],
|
||||
[242, 324],
|
||||
[241, 325],
|
||||
[240, 326]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "river_2",
|
||||
"name": null,
|
||||
"name": "Pale Run",
|
||||
"path": [
|
||||
[
|
||||
74,
|
||||
79
|
||||
],
|
||||
[
|
||||
75,
|
||||
80
|
||||
],
|
||||
[
|
||||
76,
|
||||
80
|
||||
],
|
||||
[
|
||||
77,
|
||||
79
|
||||
],
|
||||
[
|
||||
78,
|
||||
78
|
||||
],
|
||||
[
|
||||
79,
|
||||
77
|
||||
],
|
||||
[
|
||||
80,
|
||||
76
|
||||
],
|
||||
[
|
||||
81,
|
||||
75
|
||||
],
|
||||
[
|
||||
82,
|
||||
75
|
||||
]
|
||||
[74, 79],
|
||||
[75, 80],
|
||||
[76, 80],
|
||||
[77, 79],
|
||||
[78, 78],
|
||||
[79, 77],
|
||||
[80, 76],
|
||||
[81, 75],
|
||||
[82, 75]
|
||||
]
|
||||
}
|
||||
],
|
||||
"oceans": [
|
||||
{
|
||||
"id": "water_2",
|
||||
"name": null,
|
||||
"name": "Rethain Sea",
|
||||
"kind": "ocean",
|
||||
"center": [
|
||||
133,
|
||||
403
|
||||
],
|
||||
"center": [133, 403],
|
||||
"area_fraction": 0.1859
|
||||
},
|
||||
{
|
||||
"id": "water_3",
|
||||
"name": null,
|
||||
"name": "Lenden Ocean",
|
||||
"kind": "ocean",
|
||||
"center": [
|
||||
169,
|
||||
124
|
||||
],
|
||||
"center": [169, 124],
|
||||
"area_fraction": 0.2044
|
||||
},
|
||||
{
|
||||
"id": "water_5",
|
||||
"name": null,
|
||||
"name": "Corsam Lake",
|
||||
"kind": "lake",
|
||||
"center": [
|
||||
138,
|
||||
236
|
||||
],
|
||||
"center": [138, 236],
|
||||
"area_fraction": 0.0115
|
||||
},
|
||||
{
|
||||
"id": "water_11",
|
||||
"name": null,
|
||||
"name": "Selet Basin",
|
||||
"kind": "lake",
|
||||
"center": [
|
||||
220,
|
||||
336
|
||||
],
|
||||
"center": [220, 336],
|
||||
"area_fraction": 0.0082
|
||||
}
|
||||
],
|
||||
"mountain_ranges": [
|
||||
{
|
||||
"id": "range_1",
|
||||
"name": null,
|
||||
"center": [
|
||||
24,
|
||||
46
|
||||
],
|
||||
"peak": [
|
||||
20,
|
||||
44
|
||||
],
|
||||
"name": "Durneth Range",
|
||||
"center": [24, 46],
|
||||
"peak": [20, 44],
|
||||
"area_cells": 3904
|
||||
},
|
||||
{
|
||||
"id": "range_2",
|
||||
"name": null,
|
||||
"center": [
|
||||
16,
|
||||
366
|
||||
],
|
||||
"peak": [
|
||||
14,
|
||||
361
|
||||
],
|
||||
"name": "Brantfell",
|
||||
"center": [16, 366],
|
||||
"peak": [14, 361],
|
||||
"area_cells": 1162
|
||||
},
|
||||
{
|
||||
"id": "range_3",
|
||||
"name": null,
|
||||
"center": [
|
||||
18,
|
||||
206
|
||||
],
|
||||
"peak": [
|
||||
14,
|
||||
188
|
||||
],
|
||||
"name": "Keslar Spine",
|
||||
"center": [18, 206],
|
||||
"peak": [14, 188],
|
||||
"area_cells": 1294
|
||||
},
|
||||
{
|
||||
"id": "range_4",
|
||||
"name": null,
|
||||
"center": [
|
||||
35,
|
||||
488
|
||||
],
|
||||
"peak": [
|
||||
22,
|
||||
511
|
||||
],
|
||||
"name": "Pallach Heights",
|
||||
"center": [35, 488],
|
||||
"peak": [22, 511],
|
||||
"area_cells": 1070
|
||||
},
|
||||
{
|
||||
"id": "range_5",
|
||||
"name": null,
|
||||
"center": [
|
||||
34,
|
||||
305
|
||||
],
|
||||
"peak": [
|
||||
35,
|
||||
304
|
||||
],
|
||||
"name": "Tember Ridge",
|
||||
"center": [34, 305],
|
||||
"peak": [35, 304],
|
||||
"area_cells": 623
|
||||
},
|
||||
{
|
||||
"id": "range_6",
|
||||
"name": null,
|
||||
"center": [
|
||||
33,
|
||||
396
|
||||
],
|
||||
"peak": [
|
||||
31,
|
||||
396
|
||||
],
|
||||
"name": "Holt Spur",
|
||||
"center": [33, 396],
|
||||
"peak": [31, 396],
|
||||
"area_cells": 208
|
||||
},
|
||||
{
|
||||
"id": "range_7",
|
||||
"name": null,
|
||||
"center": [
|
||||
60,
|
||||
287
|
||||
],
|
||||
"peak": [
|
||||
62,
|
||||
290
|
||||
],
|
||||
"name": "The Golvane",
|
||||
"center": [60, 287],
|
||||
"peak": [62, 290],
|
||||
"area_cells": 832
|
||||
},
|
||||
{
|
||||
"id": "range_9",
|
||||
"name": null,
|
||||
"center": [
|
||||
56,
|
||||
245
|
||||
],
|
||||
"peak": [
|
||||
57,
|
||||
245
|
||||
],
|
||||
"name": "Cresswell Scarp",
|
||||
"center": [56, 245],
|
||||
"peak": [57, 245],
|
||||
"area_cells": 76
|
||||
}
|
||||
],
|
||||
"roads": [],
|
||||
"cities": [],
|
||||
"railroads": [],
|
||||
"pois": []
|
||||
}
|
||||
"roads": [
|
||||
{
|
||||
"id": "road_0",
|
||||
"name": "Aldren–Sethvale Road",
|
||||
"kind": "commercial",
|
||||
"path": [
|
||||
[145, 235],
|
||||
[140, 275],
|
||||
[135, 320],
|
||||
[132, 365],
|
||||
[130, 390],
|
||||
[130, 408]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "road_1",
|
||||
"name": "Aldren–Caldenmere Road",
|
||||
"kind": "commercial",
|
||||
"path": [
|
||||
[145, 235],
|
||||
[132, 215],
|
||||
[118, 192],
|
||||
[102, 168],
|
||||
[88, 148]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "road_2",
|
||||
"name": "Sethvale–Caldenmere Circuit",
|
||||
"kind": "regional",
|
||||
"path": [
|
||||
[130, 408],
|
||||
[112, 355],
|
||||
[100, 295],
|
||||
[93, 220],
|
||||
[88, 148]
|
||||
]
|
||||
}
|
||||
],
|
||||
"cities": [
|
||||
{
|
||||
"id": "city_0",
|
||||
"name": "Aldren",
|
||||
"kind": "capital",
|
||||
"center": [145, 235],
|
||||
"population": 250000000
|
||||
},
|
||||
{
|
||||
"id": "city_1",
|
||||
"name": "Sethvale",
|
||||
"kind": "city",
|
||||
"center": [130, 408],
|
||||
"population": 80000000
|
||||
},
|
||||
{
|
||||
"id": "city_2",
|
||||
"name": "Caldenmere",
|
||||
"kind": "city",
|
||||
"center": [88, 148],
|
||||
"population": 55000000
|
||||
}
|
||||
],
|
||||
"railroads": [
|
||||
{
|
||||
"id": "railroad_0",
|
||||
"name": "Lendel Express",
|
||||
"kind": "passenger_freight",
|
||||
"path": [
|
||||
[145, 235],
|
||||
[138, 295],
|
||||
[132, 352],
|
||||
[130, 408]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "railroad_1",
|
||||
"name": "Northern Line",
|
||||
"kind": "freight",
|
||||
"path": [
|
||||
[145, 235],
|
||||
[122, 200],
|
||||
[102, 170],
|
||||
[88, 148]
|
||||
]
|
||||
}
|
||||
],
|
||||
"pois": [
|
||||
{
|
||||
"id": "poi_0",
|
||||
"name": "Settlement House",
|
||||
"kind": "institutional",
|
||||
"center": [144, 234]
|
||||
},
|
||||
{
|
||||
"id": "poi_1",
|
||||
"name": "The Registry",
|
||||
"kind": "institutional",
|
||||
"center": [146, 236]
|
||||
},
|
||||
{
|
||||
"id": "poi_2",
|
||||
"name": "Meridian Risk HQ",
|
||||
"kind": "corporate",
|
||||
"center": [145, 233]
|
||||
},
|
||||
{
|
||||
"id": "poi_3",
|
||||
"name": "Lattice Commission — Groombridge Office",
|
||||
"kind": "institutional",
|
||||
"center": [147, 235]
|
||||
},
|
||||
{
|
||||
"id": "poi_4",
|
||||
"name": "Aldren Exchange",
|
||||
"kind": "commercial",
|
||||
"center": [143, 237]
|
||||
},
|
||||
{
|
||||
"id": "poi_5",
|
||||
"name": "Groombridge Gate Terminal",
|
||||
"kind": "transit",
|
||||
"center": [148, 232]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user