diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38cbe5e..77dd89c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,7 +108,12 @@ jobs: run: | set -euo pipefail ./.tools/Godot_v${{ steps.godot-config.outputs.version }}-${{ steps.godot-config.outputs.status }}_linux.x86_64 --headless --path . --script res://tests/test_reservations.gd > reservation-tests.log 2>&1 + RES_EXIT=$? cat reservation-tests.log + if [ $RES_EXIT -ne 0 ]; then + echo "::error::Reservation tests failed (exit code $RES_EXIT)" + exit 1 + fi macos-validation: name: macOS validation diff --git a/scenes/main.tscn b/scenes/main.tscn index a416afd..ae5e899 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=5 format=3] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] @@ -96,8 +96,35 @@ size_flags_horizontal = 3 text = "2 workers active" modulate = Color(1, 1, 1, 0.6) +[node name="HudWorkerCap" type="Label" parent="Backdrop/Margin/Root/Left/HudRow"] +unique_name_in_owner = true +layout_mode = 2 +text = "0 / 2" +autowrap_mode = 3 +modulate = Color(1, 1, 1, 0.5) +theme_override_font_sizes/font_size = 11 + +[node name="HudFoodWarning" type="Label" parent="Backdrop/Margin/Root/Left/HudRow"] +unique_name_in_owner = true +layout_mode = 2 +text = "" +visible = false +autowrap_mode = 3 +modulate = Color(1, 0.5, 0.3, 0.9) +theme_override_font_sizes/font_size = 11 + + +[node name="HudGoalLabel" type="Label" parent="Backdrop/Margin/Root/Left/HudRow"] +unique_name_in_owner = true +layout_mode = 2 +text = "" +visible = false +autowrap_mode = 3 +modulate = Color(0.6, 0.9, 1, 0.7) +theme_override_font_sizes/font_size = 11 [node name="ActivityLabel" type="Label" parent="Backdrop/Margin/Root/Left"] unique_name_in_owner = true + layout_mode = 2 text = "Activity" autowrap_mode = 3 @@ -539,3 +566,47 @@ unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 text = "Reset" + +[node name="EventDrawerLabel" type="Label" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 8 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 20.0 +offset_top = -40.0 +offset_right = -20.0 +offset_bottom = -10.0 +text = "Last: —" +autowrap_mode = 3 +modulate = Color(1, 1, 1, 0.75) +theme_override_font_sizes/font_size = 12 + +[node name="EventDrawerPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 20.0 +offset_top = -350.0 +offset_right = -20.0 +offset_bottom = -45.0 + +[node name="EventDrawerMargin" type="MarginContainer" parent="./EventDrawerPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 6 +theme_override_constants/margin_top = 6 +theme_override_constants/margin_right = 6 +theme_override_constants/margin_bottom = 6 + +[node name="EventDrawerLog" type="Label" parent="./EventDrawerPanel/EventDrawerMargin"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +autowrap_mode = 3 +text = "No events yet." +theme_override_font_sizes/font_size = 11 +modulate = Color(1, 1, 1, 0.7) diff --git a/scripts/main.gd b/scripts/main.gd index 0ce66e4..857556d 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -49,6 +49,12 @@ const ColonyStance := preload("res://scripts/colony_stance.gd") @onready var dock_side_option: OptionButton = %DockSideOption @onready var tick_speed_slider: HSlider = %TickSpeedSlider @onready var tick_speed_value: Label = %TickSpeedValue +@onready var hud_worker_cap: Label = %HudWorkerCap +@onready var hud_food_warning: Label = %HudFoodWarning +@onready var hud_goal_label: Label = %HudGoalLabel +@onready var event_drawer_label: Label = %EventDrawerLabel +@onready var event_drawer_panel: PanelContainer = %EventDrawerPanel +@onready var event_drawer_log: Label = %EventDrawerLog var tile_views: Array[Dictionary] = [] var state: Dictionary = {} @@ -87,6 +93,7 @@ var bottom_button_row: HBoxContainer var game_active := false var active_goal: Dictionary = {} var completed_goal_ids: Array = [] +var event_drawer_visible := false func make_panel_style(bg: Color, border: Color, corner_radius: int = 12) -> StyleBoxFlat: var style := StyleBoxFlat.new() @@ -671,6 +678,12 @@ func wire_controls() -> void: %BuildDownButton.pressed.connect(func() -> void: move_priority("build", 1)) tick_speed_slider.value_changed.connect(_on_tick_speed_changed) %SettingsCloseButton.pressed.connect(close_settings) + # Event drawer toggle (issue #139) + event_drawer_label.mouse_filter = Control.MOUSE_FILTER_STOP + event_drawer_label.gui_input.connect(func(event): + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + toggle_event_drawer() + ) %RecruitButton.pressed.connect(_on_recruit_worker_pressed) func load_or_boot() -> void: @@ -1340,9 +1353,11 @@ func _on_tick() -> void: # Check goal completion and rotate if not active_goal.is_empty() and RotatingGoal.is_goal_complete(active_goal): + var goal_id = String(active_goal.get("id", "unknown")) var new_goal = RotatingGoal.rotate_after_completion(active_goal, completed_goal_ids) completed_goal_ids.append(active_goal["id"]) active_goal = new_goal + push_event("Goal completed: %s. The colony moves on." % goal_id) persist() state.workers = state.workers render_all() @@ -1735,9 +1750,65 @@ func render_all() -> void: render_worker_overlay() render_goal() render_sidebar() + render_hud_row() + render_event_drawer() render_build_buttons() render_stance_toggle() + +func render_hud_row() -> void: + """Render compact HUD row: worker cap, food warning, active goal progress.""" + if not is_instance_valid(hud_worker_cap): + return + + var current_workers := active_worker_count() + var worker_cap_count := get_worker_cap() + hud_worker_cap.text = "%d / %d" % [current_workers, worker_cap_count] + hud_worker_cap.visible = true + + # Food/upkeep warning — only show when relevant (low or starving) + if is_instance_valid(hud_food_warning): + var food_level := get_low_food_level() + match food_level: + "starving": + hud_food_warning.text = "⚠ STARVING" + hud_food_warning.visible = true + "low": + hud_food_warning.text = "⚠ LOW FOOD" + hud_food_warning.visible = true + _: + hud_food_warning.visible = false + + # Active goal progress — show compactly in HUD row + if is_instance_valid(hud_goal_label): + if not active_goal.is_empty(): + var goal_type := String(active_goal.get("type", "")) + var progress := int(active_goal.get("current_progress", 0)) + var target := int(active_goal.get("target", {}).get("amount", 0)) + var is_complete := RotatingGoal.is_goal_complete(active_goal) + + var goal_text := "" + match goal_type: + RotatingGoal.GOAL_TYPE_RESOURCE: + var resource := String(active_goal.get("target", {}).get("resource", "")) + goal_text = "Goal: %s" % resource + RotatingGoal.GOAL_TYPE_BUILD: + var build_kind := String(active_goal.get("target", {}).get("build_kind", "")) + goal_text = "Build: %s" % cap(build_kind) + RotatingGoal.GOAL_TYPE_BUILD_COMPLETE: + goal_text = "Goal: Finish a build" + + # Add progress only when useful (not at 0, not complete) + if target > 0 and progress > 0 and not is_complete: + goal_text += " (%d/%d)" % [progress, target] + elif is_complete: + goal_text += " ✓" + + hud_goal_label.text = goal_text + hud_goal_label.visible = true + else: + hud_goal_label.visible = false + func render_world() -> void: for y in grid_h: for x in grid_w: @@ -2257,6 +2328,35 @@ func is_structure_complete(kind: String) -> bool: return true return false + +func toggle_event_drawer() -> void: + event_drawer_visible = not event_drawer_visible + event_drawer_panel.visible = event_drawer_visible + render_all() + + +func render_event_drawer() -> void: + """Render the compact event drawer: collapsed label + expanded log.""" + if not is_instance_valid(event_drawer_label): + return + + # Update collapsed label with latest event + var events = state.get("events", []) + if not events.is_empty(): + var latest_text = String(events[0].get("text", "—")) + event_drawer_label.text = "Last: " + latest_text + else: + event_drawer_label.text = "Last: —" + + # Update expanded log with recent history (last 6 events) + if is_instance_valid(event_drawer_log): + var lines := [] + for i in range(mini(events.size(), 6)): + var entry = events[i] + lines.append("t%02d %s" % [int(entry.tick), String(entry.get("text", ""))]) + event_drawer_log.text = "\n".join(lines) if not lines.is_empty() else "No events yet." + + func push_event(text: String) -> void: state.events.push_front({"tick": tick, "text": text}) while state.events.size() > 8: diff --git a/tests/test_food_upkeep.gd b/tests/test_food_upkeep.gd index e59460a..8256077 100644 --- a/tests/test_food_upkeep.gd +++ b/tests/test_food_upkeep.gd @@ -1,4 +1,4 @@ -extends TestSuite +extends SceneTree # Tests for food upkeep model (issue #147, links to #133). # Validates: base workers no pressure, extra workers create pressure, # low-food slowdown, starvation pause, and food-gathering bias. diff --git a/tests/test_recruit_worker.gd b/tests/test_recruit_worker.gd index 9bcdfd8..38039f9 100644 --- a/tests/test_recruit_worker.gd +++ b/tests/test_recruit_worker.gd @@ -8,7 +8,7 @@ var test_fail := 0 func _initialize() -> void: # Load main.gd and create an instance (no UI nodes needed for logic tests) - var main_script: GDScript = preload("res://scripts/main.gd") + var main_script: GDScript = load("res://scripts/main.gd") as GDScript var main: Control = main_script.new() test_can_recruit_with_capacity(main) @@ -80,7 +80,7 @@ func test_recruit_adds_worker_to_state(main: Control) -> void: {"name": "Jun", "task": {"kind": "", "data": {}}}, ]) _assert(main.can_recruit_worker(), "precondition: can recruit") - var initial_count := main.state.workers.size() + var initial_count: int = main.state.workers.size() main.recruit_worker() _assert_eq(main.state.workers.size(), initial_count + 1, "recruit: state workers count increases by 1") diff --git a/tests/test_resource_trends.gd b/tests/test_resource_trends.gd index cd3954f..e81548d 100644 --- a/tests/test_resource_trends.gd +++ b/tests/test_resource_trends.gd @@ -112,7 +112,7 @@ func _test_trend_stable_value() -> bool: func _get_trend_mock(resource_name: String, current_val: int, previous_val: int = -1) -> String: """Simulate _get_trend by setting state.resources and prev_resources then calling the method.""" - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -171,7 +171,7 @@ func _test_get_trend_unknown_resource() -> bool: # --- stockpile_summary_text arrow embedding tests --- func _test_summary_contains_rising_arrow() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -192,7 +192,7 @@ func _test_summary_contains_rising_arrow() -> bool: return summary.find(rising_arrow) >= 0 func _test_summary_contains_stable_arrow() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -220,7 +220,7 @@ func _test_summary_contains_stable_arrow() -> bool: ## Safe upper bound: ~35 characters for 280px sidebar, ~40 for 320px sidebar. func _test_compact_summary_fits_bottom_dock() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -247,7 +247,7 @@ func _test_compact_summary_fits_bottom_dock() -> bool: return true func _test_compact_summary_fits_side_dock() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -273,7 +273,7 @@ func _test_compact_summary_fits_side_dock() -> bool: return true func _test_noncompact_first_line_fits() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -304,7 +304,7 @@ func _test_noncompact_first_line_fits() -> bool: return true func _test_all_arrows_in_compact() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -341,7 +341,7 @@ func _test_all_arrows_in_compact() -> bool: return true func _test_all_arrows_in_noncompact() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} @@ -378,7 +378,7 @@ func _test_all_arrows_in_noncompact() -> bool: return true func _test_extreme_values_fit_compact() -> bool: - var main := Globals.get_node("/root/Main") as Node + var main := get_node("/root/Main") as Node if main == null: return {"ok": false, "msg": "Main autoload not found"} diff --git a/tests/test_runner.gd b/tests/test_runner.gd index ed7fa71..282657e 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -24,6 +24,7 @@ func _initialize() -> void: test_save_version_tracking(game_state) test_settings_roundtrip(game_state) test_event_log(game_state) + test_bounded_event_log(game_state) test_clear_game(game_state) test_save_migration_hardening(game_state) test_resource_reservations(game_state) @@ -376,6 +377,37 @@ func test_event_log(gs: Node) -> void: _assert_eq(loaded_events[2].get("text", ""), "Hut built", "event_log: last event text") + +func test_bounded_event_log(gs: Node) -> void: + print("") + print("--- bounded event log ---") + + # Simulate push_event bounded behavior: max 8 events, LIFO eviction + var events := [] + const MAX_EVENTS := 8 + + for i in range(12): + events.push_front({"tick": i, "text": "Event %d" % i}) + while events.size() > MAX_EVENTS: + events.pop_back() + + _assert_eq(events.size(), MAX_EVENTS, "bounded_event_log: capped at 8") + # First event should be the most recent (11), last should be oldest kept (4) + _assert_eq(int(events[0].get("tick", -1)), 11, "bounded_event_log: first is newest (11)") + _assert_eq(int(events[MAX_EVENTS - 1].get("tick", -1)), 4, "bounded_event_log: last is oldest kept (4)") + + # Verify eviction count: 12 pushed - 8 kept = 4 evicted + var evicted_count := 12 - MAX_EVENTS + _assert_eq(evicted_count, 4, "bounded_event_log: 4 events evicted") + + # Empty log stays empty + var empty_events := [] + _assert_empty(empty_events, "bounded_event_log: empty log is empty") + + # Single event fits without eviction + empty_events.push_front({"tick": 0, "text": "Single"}) + _assert_eq(empty_events.size(), 1, "bounded_event_log: single event size 1") + func test_clear_game(gs: Node) -> void: print("") print("--- clear game ---") diff --git a/tests/test_worker_cap.gd b/tests/test_worker_cap.gd index 113f23f..72c23c6 100644 --- a/tests/test_worker_cap.gd +++ b/tests/test_worker_cap.gd @@ -8,7 +8,7 @@ var test_fail := 0 func _initialize() -> void: # Load main.gd and create an instance (no UI nodes needed for logic tests) - var main_script: GDScript = preload("res://scripts/main.gd") + var main_script: GDScript = load("res://scripts/main.gd") as GDScript var main: Control = main_script.new() test_base_cap_no_structures(main)