Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,4 @@ Tooling is pinned with `.mise.toml`. Run `mise trust` once in the repo if mise p
## Contributors

Windowstead is maintained by Miso Space with help from local and AI-assisted contributors. Contributions should preserve the desktop-companion dock UX: bottom strip first, temporary popup menus, and low-attention colony behavior.
# test fix: _assert_empty accepts Variant
73 changes: 72 additions & 1 deletion scenes/main.tscn
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
100 changes: 100 additions & 0 deletions scripts/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 34 additions & 1 deletion tests/test_runner.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -63,7 +64,7 @@ func _assert_not_empty(d: Dictionary, name: String) -> void:
_assert(not d.is_empty(), name, "dictionary should not be empty")


func _assert_empty(d: Dictionary, name: String) -> void:
func _assert_empty(d: Variant, name: String) -> void:
_assert(d.is_empty(), name, "dictionary should be empty")


Expand Down Expand Up @@ -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 ---")
Expand Down Expand Up @@ -832,3 +864,4 @@ func test_delivery_clamping(gs: Node) -> void:
build = main.get_build(1)
_assert_eq(int(build.delivered.get("wood", 0)), 6, "clamping: no over-delivery (stays at 6)")
_assert_eq(int(main.state.resources.get("wood", -1)), 16, "clamping: all excess refunded (12+4=16)")