Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions scripts/constants.gd
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,26 @@ const STARVATION_FOOD_THRESHOLD := 1 # food <= this → workers pause
# At STARVATION_FOOD_THRESHOLD, workers stop (0% speed) unless gathering food.
const LOW_FOOD_SPEED_FACTOR := 0.5
const STARVATION_SPEED_FACTOR := 0.0

# ── Worker intent icons and idle reasons (issue #136) ─────────────────────────
# Maps task kind + state to a compact emoji icon shown in the crew panel.
# Also provides human-readable reason text for idle states.

const WORKER_INTENT_ICONS := {
"gather_wood": "🪓",
"gather_stone": "⛏",
"gather_food": "🫐",
"haul": "📦",
"build_hut": "🏗",
"build_workshop": "🏗",
"build_garden": "🏗",
"idle": "💤",
"break": "☕",
}

const WORKER_INTENT_REASONS := {
"idle_no_task": "No valid task",
"idle_stockpile_full": "Stockpile full",
"idle_no_reachable_build": "No reachable build task",
"idle_food_priority": "Food priority active",
}
118 changes: 114 additions & 4 deletions scripts/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -1959,10 +1959,23 @@ func render_sidebar() -> void:
for child in crew_list.get_children():
child.queue_free()
for worker in state.workers:
var label := Label.new()
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.text = "%s • %s • %s" % [worker.name, task_name(worker), carrying_name(worker.carrying)]
crew_list.add_child(label)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 6)
var icon_label := Label.new()
icon_label.text = worker_intent_icon(worker)
icon_label.modulate = Color(1, 1, 1, 0.95)
hbox.add_child(icon_label)
var name_label := Label.new()
name_label.text = "%s" % worker.name
name_label.add_theme_color_override("font_color", WORKER_BADGE_COLORS.get(worker.name, Color.WHITE))
hbox.add_child(name_label)
var detail_label := Label.new()
detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
var intent_text := worker_intent_text(worker)
detail_label.text = intent_text
detail_label.modulate = Color(0.86, 0.9, 0.95, 0.84)
hbox.add_child(detail_label)
crew_list.add_child(hbox)
event_log.clear()
for entry in state.events:
event_log.append_text("t%02d %s\n" % [int(entry.tick), String(entry.text)])
Expand Down Expand Up @@ -2433,5 +2446,102 @@ func get_reserved(resource: String) -> int:
return 0
return int(state.reserved_resources.get(resource, 0))


# ── Worker intent icons and text (issue #136) ────────────────────────────────

func worker_intent_icon(worker: Dictionary) -> String:
"""Return the compact emoji icon for a worker's current intent."""
if int(worker.get("break_ticks", 0)) > 0:
return Constants.WORKER_INTENT_ICONS.get("break", "☕")
var task: Dictionary = worker.get("task", {})
if task.is_empty():
return Constants.WORKER_INTENT_ICONS.get("idle", "💤")
var kind := String(task.get("kind", ""))
match kind:
"gather":
var resource := String(task.get("resource", ""))
match resource:
"wood": return Constants.WORKER_INTENT_ICONS.get("gather_wood", "🪓")
"stone": return Constants.WORKER_INTENT_ICONS.get("gather_stone", "⛏")
"food": return Constants.WORKER_INTENT_ICONS.get("gather_food", "🫐")
return Constants.WORKER_INTENT_ICONS.get("idle", "💤")
"haul": return Constants.WORKER_INTENT_ICONS.get("haul", "📦")
"build":
var build_kind := String(task.get("build_kind", ""))
if build_kind.is_empty():
var build_id := int(task.get("build_id", -1))
for b in state.get("builds", []):
if int(b.id) == build_id:
build_kind = String(b.kind)
break
match build_kind:
"hut": return Constants.WORKER_INTENT_ICONS.get("build_hut", "🏗")
"workshop": return Constants.WORKER_INTENT_ICONS.get("build_workshop", "🏗")
"garden": return Constants.WORKER_INTENT_ICONS.get("build_garden", "🏗")
return Constants.WORKER_INTENT_ICONS.get("build_hut", "🏗")
return Constants.WORKER_INTENT_ICONS.get("idle", "💤")


func worker_intent_text(worker: Dictionary) -> String:
"""Return human-readable intent text for a worker, including idle/blocked reasons."""
if int(worker.get("break_ticks", 0)) > 0:
return "on break"
var task: Dictionary = worker.get("task", {})
if task.is_empty():
var idle_reason := worker_idle_reason(worker)
return Constants.WORKER_INTENT_REASONS.get(idle_reason, "No valid task")
var kind := String(task.get("kind", ""))
match kind:
"gather":
var resource := String(task.get("resource", ""))
match resource:
"wood": return "gathering wood"
"stone": return "gathering stone"
"food": return "gathering food"
return "gathering"
"haul":
var resource := String(task.get("resource", ""))
if int(task.get("build_id", -1)) >= 0:
var build := get_build(int(task.build_id))
if not build.is_empty():
return "hauling %s to %s" % [resource, build.kind]
return "hauling %s" % resource
"build":
var build_kind := String(task.get("build_kind", ""))
if build_kind.is_empty():
var build_id := int(task.get("build_id", -1))
for b in state.get("builds", []):
if int(b.id) == build_id:
build_kind = String(b.kind)
break
return "building %s" % build_kind
return "working"


func worker_idle_reason(worker: Dictionary) -> String:
"""Determine why a worker is idle and return the reason key."""
for build in state.get("builds", []):
if not bool(build.complete):
var costs: Dictionary = Constants.BUILD_COSTS.get(String(build.kind), {})
var has_pending_haul := false
for resource in costs.keys():
var delivered := int(build.delivered.get(resource, 0))
var cost := int(costs[resource])
if delivered < cost and int(state.resources.get(resource, 0)) > 0:
has_pending_haul = true
break
if has_pending_haul:
var stockpile_full := true
for resource in costs.keys():
if int(state.resources.get(resource, 0)) == 0:
stockpile_full = false
break
if stockpile_full:
return "idle_stockpile_full"
return "idle_no_reachable_build"
if should_bias_to_food_gathering():
return "idle_food_priority"
return "idle_no_task"

func cap(text: String) -> String:
return text.substr(0, 1).to_upper() + text.substr(1)
224 changes: 224 additions & 0 deletions tests/test_worker_intent.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
## Tests for worker intent icons and text (issue #136).
## Verifies: icon/text mapping for all task kinds, idle reasons, break state.

extends SceneTree

var test_pass := 0
var test_fail := 0

func _initialize() -> void:
var main_script: GDScript = preload("res://scripts/main.gd")
var main: Control = main_script.new()

test_worker_intent_icon_gather_wood(main)
test_worker_intent_icon_gather_stone(main)
test_worker_intent_icon_gather_food(main)
test_worker_intent_icon_haul(main)
test_worker_intent_icon_build_hut(main)
test_worker_intent_icon_idle(main)
test_worker_intent_icon_break(main)
test_worker_intent_text_gather_wood(main)
test_worker_intent_text_gather_stone(main)
test_worker_intent_text_gather_food(main)
test_worker_intent_text_haul_to_build(main)
test_worker_intent_text_haul_to_stockpile(main)
test_worker_intent_text_build_hut(main)
test_worker_intent_text_idle_no_task(main)
test_worker_intent_text_break(main)
test_worker_idle_reason_no_task(main)
test_worker_idle_reason_food_priority(main)
test_worker_idle_reason_stockpile_full(main)
test_worker_intent_icon_build_id_fallback(main)

print("")
print("=== test_worker_intent summary: %d passed, %d failed ===" % [test_pass, test_fail])
if test_fail > 0:
print("FAILURES DETECTED")
quit(1)
else:
print("test_worker_intent: ok")
quit(0)


func _assert(condition: Variant, name: String, detail: String = "") -> void:
if not condition:
test_fail += 1
if not detail.is_empty():
print("TEST %s: FAIL — %s" % [name, detail])
else:
print("TEST %s: FAIL" % name)
else:
test_pass += 1
print("TEST %s: PASS" % name)


func _assert_eq(actual: Variant, expected: Variant, name: String) -> void:
_assert(actual == expected, name, "expected %s, got %s" % [str(expected), str(actual)])


# ── Icon Tests ───────────────────────────────────────────────────────────────

func test_worker_intent_icon_gather_wood(main: Control) -> void:
print("")
print("--- icon: gather wood ---")
var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "wood"}, "break_ticks": 0}
_assert_eq(main.worker_intent_icon(worker), "🪓", "gather wood icon is 🪓")


func test_worker_intent_icon_gather_stone(main: Control) -> void:
print("")
print("--- icon: gather stone ---")
var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "stone"}, "break_ticks": 0}
_assert_eq(main.worker_intent_icon(worker), "⛏", "gather stone icon is ⛏")


func test_worker_intent_icon_gather_food(main: Control) -> void:
print("")
print("--- icon: gather food ---")
var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "food"}, "break_ticks": 0}
_assert_eq(main.worker_intent_icon(worker), "🫐", "gather food icon is 🫐")


func test_worker_intent_icon_haul(main: Control) -> void:
print("")
print("--- icon: haul ---")
var worker := {"name": "Jun", "task": {"kind": "haul", "resource": "wood"}, "break_ticks": 0}
_assert_eq(main.worker_intent_icon(worker), "📦", "haul icon is 📦")


func test_worker_intent_icon_build_hut(main: Control) -> void:
print("")
print("--- icon: build hut ---")
var worker := {"name": "Jun", "task": {"kind": "build", "build_kind": "hut"}, "break_ticks": 0}
_assert_eq(main.worker_intent_icon(worker), "🏗", "build hut icon is 🏗")


func test_worker_intent_icon_idle(main: Control) -> void:
print("")
print("--- icon: idle ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 0}
_assert_eq(main.worker_intent_icon(worker), "💤", "idle icon is 💤")


func test_worker_intent_icon_break(main: Control) -> void:
print("")
print("--- icon: break ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 5}
_assert_eq(main.worker_intent_icon(worker), "☕", "break icon is ☕")


# ── Text Tests ───────────────────────────────────────────────────────────────

func test_worker_intent_text_gather_wood(main: Control) -> void:
print("")
print("--- text: gather wood ---")
var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "wood"}, "break_ticks": 0}
_assert_eq(main.worker_intent_text(worker), "gathering wood", "gather wood text is correct")


func test_worker_intent_text_gather_stone(main: Control) -> void:
print("")
print("--- text: gather stone ---")
var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "stone"}, "break_ticks": 0}
_assert_eq(main.worker_intent_text(worker), "gathering stone", "gather stone text is correct")


func test_worker_intent_text_gather_food(main: Control) -> void:
print("")
print("--- text: gather food ---")
var worker := {"name": "Jun", "task": {"kind": "gather", "resource": "food"}, "break_ticks": 0}
_assert_eq(main.worker_intent_text(worker), "gathering food", "gather food text is correct")


func test_worker_intent_text_haul_to_build(main: Control) -> void:
print("")
print("--- text: haul to build ---")
var worker := {"name": "Jun", "task": {"kind": "haul", "resource": "wood", "build_id": 1}, "break_ticks": 0}
main.state = {
"builds": [{"id": 1, "kind": "hut", "complete": false}],
"workers": [worker],
}
var text := main.worker_intent_text(worker)
_assert("hauling wood to hut" in text, "haul to build includes build kind")


func test_worker_intent_text_haul_to_stockpile(main: Control) -> void:
print("")
print("--- text: haul to stockpile ---")
var worker := {"name": "Jun", "task": {"kind": "haul", "resource": "stone"}, "break_ticks": 0}
_assert_eq(main.worker_intent_text(worker), "hauling stone", "haul to stockpile text is correct")


func test_worker_intent_text_build_hut(main: Control) -> void:
print("")
print("--- text: build hut ---")
var worker := {"name": "Jun", "task": {"kind": "build", "build_kind": "hut"}, "break_ticks": 0}
_assert_eq(main.worker_intent_text(worker), "building hut", "build hut text is correct")


func test_worker_intent_text_idle_no_task(main: Control) -> void:
print("")
print("--- text: idle no task ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 0}
main.state = {
"builds": [],
"resources": {"wood": 0, "stone": 0, "food": 2},
}
var text := main.worker_intent_text(worker)
_assert("No valid task" in text, "idle with no builds shows 'No valid task'")


func test_worker_intent_text_break(main: Control) -> void:
print("")
print("--- text: break ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 3}
_assert_eq(main.worker_intent_text(worker), "on break", "break text is 'on break'")


# ── Idle Reason Tests ────────────────────────────────────────────────────────

func test_worker_idle_reason_no_task(main: Control) -> void:
print("")
print("--- idle reason: no task ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 0}
main.state = {
"builds": [],
"resources": {"wood": 0, "stone": 0, "food": 2},
}
_assert_eq(main.worker_idle_reason(worker), "idle_no_task", "no builds → idle_no_task")


func test_worker_idle_reason_food_priority(main: Control) -> void:
print("")
print("--- idle reason: food priority ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 0}
main.state = {
"builds": [],
"resources": {"wood": 1, "stone": 1, "food": 2},
}
# food=2 <= LOW_FOOD_THRESHOLD (3), so should_bias_to_food_gathering() → true
_assert_eq(main.worker_idle_reason(worker), "idle_food_priority", "low food → idle_food_priority")


func test_worker_idle_reason_stockpile_full(main: Control) -> void:
print("")
print("--- idle reason: stockpile full ---")
var worker := {"name": "Jun", "task": {}, "break_ticks": 0}
main.state = {
"builds": [{"id": 1, "kind": "hut", "complete": false}],
"resources": {"wood": 100, "stone": 100},
}
# Build needs wood+stone, both are available and delivered=0, so has_pending_haul=true
# All costs resources > 0 → stockpile_full=true
_assert_eq(main.worker_idle_reason(worker), "idle_stockpile_full", "build waiting for resources with full stockpile → idle_stockpile_full")


func test_worker_intent_icon_build_id_fallback(main: Control) -> void:
print("")
print("--- icon: build_id fallback ---")
var worker := {"name": "Jun", "task": {"kind": "build", "build_id": 1}, "break_ticks": 0}
main.state = {
"builds": [{"id": 1, "kind": "hut", "complete": false}],
"workers": [worker],
}
_assert_eq(main.worker_intent_icon(worker), "🏗", "build_id fallback resolves to hut icon")