- Bump client protocol version 20 → 21 to match server (#822) - Fix render_priority parameter name (was _render_priority, unused prefix) - Fix debug console type inference (var sub := → var sub: String =) - Economics panel: add population row, improve key hint text - Stance indicator: hide on gameplay_occluded (D-170 fullscreen apps) - Remove 5 broken clothing items from manifest and delete their GLBs (boots_work, coveralls_basic, jacket_utility, pants_cargo, shirt_henley) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
318 lines
12 KiB
GDScript
318 lines
12 KiB
GDScript
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())
|
||
|
||
# ── Population + GDP strip ────────────────────────────────────────────────
|
||
var pop_str: String = node.get("population", "—")
|
||
_panel.add_component(ImplantDataRow.new("pop " + pop_str))
|
||
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("[ prev system ] next 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()
|