diff --git a/scripts/constants.gd b/scripts/constants.gd index 5d616f1..5246562 100644 --- a/scripts/constants.gd +++ b/scripts/constants.gd @@ -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", +} diff --git a/scripts/main.gd b/scripts/main.gd index 30184bb..45317ba 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -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)]) @@ -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) diff --git a/tests/test_worker_intent.gd b/tests/test_worker_intent.gd new file mode 100644 index 0000000..eee450d --- /dev/null +++ b/tests/test_worker_intent.gd @@ -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")