From 949d721eac5aaf8a6d21e0847e8aeef8dff2b627 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 10 Apr 2026 13:22:20 +0200 Subject: [PATCH] feat(ui): economics monitor insert panel with placeholder data (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New implant panel at implant/economics: system selector, 6-commodity price table with trend indicators, GDP strip. Composed from D-169 component library. Ring buffer caches last 20 ticks per system. Snapshot routing wired through snapshot_handler → GameState → snapshot_consumers → economics_panel. Placeholder prices shown until server ships EconomySnapshot (#822). Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/game_state.gd | 6 + client/scripts/autoloads/hud_groups.gd | 1 + client/scripts/main.gd | 8 +- client/scripts/snapshot_consumers.gd | 12 + client/scripts/snapshot_handler.gd | 6 + client/ui/hud.tscn | 8 +- client/ui/implant/economics_panel.gd | 330 +++++++++++++++++++++++++ client/ui/implant/economics_panel.tscn | 18 ++ 8 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 client/ui/implant/economics_panel.gd create mode 100644 client/ui/implant/economics_panel.tscn diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 96cc7ef79..a4e8bb363 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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}] diff --git a/client/scripts/autoloads/hud_groups.gd b/client/scripts/autoloads/hud_groups.gd index c519c82bb..a89b940a9 100644 --- a/client/scripts/autoloads/hud_groups.gd +++ b/client/scripts/autoloads/hud_groups.gd @@ -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") diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ee0aaf4b7..518786283 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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,10 @@ func _unhandled_key_input(event: InputEvent) -> void: if event is InputEventKey and event.keycode == KEY_M: if star_map: star_map.toggle_visible() + elif event is InputEventKey and event.keycode == KEY_E: + # #824: E — toggle Economics Monitor implant panel + if economics_panel: + economics_panel.toggle_visible() func _process(delta: float) -> void: diff --git a/client/scripts/snapshot_consumers.gd b/client/scripts/snapshot_consumers.gd index 8cd91334b..ca886eafb 100644 --- a/client/scripts/snapshot_consumers.gd +++ b/client/scripts/snapshot_consumers.gd @@ -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,14 @@ 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) + + # #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: diff --git a/client/scripts/snapshot_handler.gd b/client/scripts/snapshot_handler.gd index 589d79c66..a1de2d407 100644 --- a/client/scripts/snapshot_handler.gd +++ b/client/scripts/snapshot_handler.gd @@ -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") diff --git a/client/ui/hud.tscn b/client/ui/hud.tscn index 76a892348..2c3b643fa 100644 --- a/client/ui/hud.tscn +++ b/client/ui/hud.tscn @@ -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 + diff --git a/client/ui/implant/economics_panel.gd b/client/ui/implant/economics_panel.gd new file mode 100644 index 000000000..ae157a19b --- /dev/null +++ b/client/ui/implant/economics_panel.gd @@ -0,0 +1,330 @@ +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 · E 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" + + +# ============================================================================= +# Input — LEFT/RIGHT arrow navigation between systems +# ============================================================================= + + +func _gui_input(event: InputEvent) -> void: + if not visible: + return + if event is InputEventKey and event.pressed and not event.is_echo(): + match event.keycode: + KEY_LEFT: + _cycle_system(-1) + get_viewport().set_input_as_handled() + KEY_RIGHT: + _cycle_system(1) + get_viewport().set_input_as_handled() + + +func _cycle_system(direction: int) -> void: + if _systems.is_empty(): + return + _selected_idx = wrapi(_selected_idx + direction, 0, _systems.size()) + selected_system = _systems[_selected_idx].get("system_id", "") + _rebuild_panel() diff --git a/client/ui/implant/economics_panel.tscn b/client/ui/implant/economics_panel.tscn new file mode 100644 index 000000000..03ad3e3c0 --- /dev/null +++ b/client/ui/implant/economics_panel.tscn @@ -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")