extends CanvasLayer @export var simulation_manager: Node @onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel @onready var npc_inspector_label: Label = $NpcInspectorPanel/MarginContainer/NpcInspectorLabel var selected_npc_index := 0 var inspector_refresh_accumulator := 0.0 var debug_overlay_visible := true func _ready() -> void: if simulation_manager == null: push_error("VillageUI: simulation_manager missing") return if simulation_manager.has_signal("village_changed"): simulation_manager.village_changed.connect(_on_village_changed) else: push_error("VillageUI: SimulationManager has no village_changed signal") if simulation_manager.has_signal("npc_decision_recorded"): simulation_manager.npc_decision_recorded.connect(_on_npc_decision_recorded) else: push_error("VillageUI: SimulationManager has no npc_decision_recorded signal") if simulation_manager.has_signal("state_restored"): simulation_manager.state_restored.connect(_on_state_restored) if simulation_manager.has_signal("speed_changed"): simulation_manager.speed_changed.connect(_on_speed_changed) if simulation_manager.has_signal("relationship_changed"): simulation_manager.relationship_changed.connect(_on_relationship_changed) if simulation_manager.has_signal("event_knowledge_changed"): simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed) if simulation_manager.has_signal("event_knowledge_forgotten"): simulation_manager.event_knowledge_forgotten.connect(_on_event_knowledge_forgotten) if simulation_manager.has_signal("opportunity_opened"): simulation_manager.opportunity_opened.connect(_on_opportunity_opened) if simulation_manager.has_signal("opportunity_resolved"): simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) if simulation_manager.has_signal("opportunity_invalidated"): simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated) if "village" in simulation_manager: _on_village_changed(simulation_manager.village) _refresh_npc_inspector() func _process(delta: float) -> void: inspector_refresh_accumulator += delta if inspector_refresh_accumulator < 0.25: return inspector_refresh_accumulator = 0.0 _refresh_npc_inspector() func _unhandled_key_input(event: InputEvent) -> void: if not event is InputEventKey or not event.pressed or event.echo: return if event.keycode != KEY_TAB or simulation_manager.npcs.is_empty(): return selected_npc_index = (selected_npc_index + 1) % simulation_manager.npcs.size() _refresh_npc_inspector() get_viewport().set_input_as_handled() func _on_village_changed(village: SimVillage) -> void: var name_map := {} for npc in simulation_manager.npcs: name_map[npc.id] = npc.npc_name var event_lines: Array[String] = [] for event in simulation_manager.get_recent_events(3): event_lines.append(event.description(name_map)) var event_text := "\n".join(event_lines) if event_text.is_empty(): event_text = "" else: event_text = "\n" + event_text var speed_text := "" if simulation_manager.has_method("get_current_speed"): speed_text = simulation_manager.get_current_speed() var rates: Dictionary = simulation_manager.get_resource_rates() var food_rate := "" if rates["food_per_day"] > 0.01: food_rate = " (−%.0f/d)" % rates["food_per_day"] var wood_rate := "" if rates["wood_per_day"] > 0.01: wood_rate = " (−%.0f/d)" % rates["wood_per_day"] var opportunity_text := _build_active_opportunity_display() village_stats_label.text = ( """ Village [%s] Food: %s%s Wood: %s%s Safety: %s Knowledge: %s Starving: %s Food Mod: %.2f Safety Mod: %.2f Knowledge Mod: %.2f %s%s """ % [ speed_text, round(village.food), food_rate, round(village.wood), wood_rate, round(village.safety), round(village.knowledge), simulation_manager.get_starving_count(), village.food_modifier, village.safety_modifier, village.knowledge_modifier, opportunity_text, event_text, ] ) func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void: _refresh_npc_inspector() func _on_state_restored() -> void: selected_npc_index = 0 _refresh_village_display() _refresh_npc_inspector() func _on_speed_changed(_multiplier: float) -> void: if simulation_manager != null and "village" in simulation_manager: _on_village_changed(simulation_manager.village) func _on_relationship_changed( _relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord ) -> void: _refresh_npc_inspector() func _on_event_knowledge_changed(_knower_id: int, _event: EconomicEventRecord) -> void: _refresh_npc_inspector() func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord) -> void: _refresh_npc_inspector() func _on_opportunity_opened(_opportunity: RefCounted) -> void: _refresh_village_display() _refresh_npc_inspector() func _on_opportunity_resolved( _opportunity: RefCounted, _resolution_event: EconomicEventRecord ) -> void: _refresh_village_display() _refresh_npc_inspector() func _on_opportunity_invalidated(_opportunity: RefCounted) -> void: _refresh_village_display() _refresh_npc_inspector() func _refresh_village_display() -> void: if simulation_manager != null and "village" in simulation_manager: _on_village_changed(simulation_manager.village) func _refresh_npc_inspector() -> void: if not debug_overlay_visible: return if simulation_manager == null or simulation_manager.npcs.is_empty(): npc_inspector_label.text = "No villagers available" return selected_npc_index = clampi(selected_npc_index, 0, simulation_manager.npcs.size() - 1) var npc: SimNPC = simulation_manager.npcs[selected_npc_index] var profession_definition := SimulationDefinitions.get_profession(npc.profession) var profession_name := String(npc.profession) if profession_definition != null: profession_name = profession_definition.display_name var action_name := _get_action_name(npc.current_task) var destination_name := "Resolving" if not npc.target_id.is_empty(): destination_name = String(npc.target_id) var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) var reason_text := "Awaiting the next decision" var score_text := "" if decision != null: reason_text = decision.reason var score_keys: Array = decision.scores.keys() score_keys.sort() var score_rows: Array[String] = [] var rejection_rows: Array[String] = [] for action_id in score_keys: if decision.rejections.has(action_id): rejection_rows.append( ( "%s — %s" % [ _get_action_name(StringName(action_id)), String(decision.rejections[action_id]) ] ) ) continue score_rows.append( ( "%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])] ) ) if not score_rows.is_empty(): score_text = "\n\nUtility\n" + "\n".join(score_rows) if not rejection_rows.is_empty(): score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows) var event_text := _build_event_history(npc) var relationship_text := _build_relationship_display(npc) var memory_history_text := _build_memory_history_display(npc) var opportunity_text := _build_npc_opportunity_display(npc) npc_inspector_label.text = ( ( "%s · %s\n" + "Task: %s (%s)\n" + "Destination: %s\n" + "Hunger: %.0f Energy: %.0f\n" + "Carrying food: %.0f wood: %.0f\n" + "%s\n" + "%s\n" + "%s" + "Why\n%s%s\n\n" + "Own history\n%s\n\n" + "[Tab] Next villager [F10] Cinematic/debug [F11] Pantry story [F12] Reset" ) % [ npc.npc_name, profession_name, action_name, npc.task_state, destination_name, npc.hunger, npc.energy, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD), relationship_text, memory_history_text, opportunity_text, reason_text, score_text, event_text ] ) func _build_active_opportunity_display() -> String: if not simulation_manager.has_method("get_active_opportunity"): return "" var opportunity = simulation_manager.get_active_opportunity() if opportunity == null: return "" var interested_name := _get_npc_name(opportunity.get_interested_npc_id()) var resource_name := String(opportunity.get_resource_id()).capitalize() var current_amount := _get_opportunity_current_amount(opportunity) if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: var action_name := _get_opportunity_action_name(opportunity) return ( "\n\nVillage need\n" + "◆ Supply wood for blocked work\n" + "%s could not finish %s\n" % [interested_name, action_name] + "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] ) return ( "\n\nVillage need\n" + "◆ Restock the empty pantry\n" + "%s is worried about the shortage\n" % interested_name + "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] ) func _build_npc_opportunity_display(npc: SimNPC) -> String: if not simulation_manager.has_method("get_latest_opportunity_for_npc"): return "" var opportunity = simulation_manager.get_latest_opportunity_for_npc(npc.id) if opportunity == null: return "" var resource_name := String(opportunity.get_resource_id()).capitalize() if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN: var current_amount := _get_opportunity_current_amount(opportunity) if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: return ( "Open village need\n" + "◆ Find wood for %s\n" % _get_opportunity_action_name(opportunity) + ( "%s %.0f / %.0f\n\n" % [resource_name, current_amount, opportunity.get_target_amount()] ) ) return ( "Open village need\n" + "◆ Restock the empty pantry\n" + ( "%s %.0f / %.0f\n\n" % [resource_name, current_amount, opportunity.get_target_amount()] ) ) if opportunity.get_status() == OpportunityStateRecord.STATUS_INVALIDATED: var reason := ( "The interested villager died" if ( opportunity.get_invalidation_reason() == SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED ) else "The evidence became stale" ) return "Closed village need\n◆ %s\n\n" % reason if opportunity.get_status() != OpportunityStateRecord.STATUS_RESOLVED: return "" var resolution_event: EconomicEventRecord = simulation_manager.get_opportunity_resolution_event( opportunity ) var actor_name := "Someone" if resolution_event != null: var actor_id := int(resolution_event.data["actor_id"]) actor_name = "Player" if actor_id < 0 else _get_npc_name(actor_id) if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: return ( "Resolved village need\n" + "◆ %s supplied the woodpile\n" % actor_name + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] ) return ( "Resolved village need\n" + "◆ %s restocked the pantry\n" % actor_name + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] ) func _get_opportunity_current_amount(opportunity: OpportunityStateRecord) -> float: var storage: StorageStateRecord match opportunity.get_target_id(): SimulationIds.STORAGE_VILLAGE_PANTRY: storage = simulation_manager.get_pantry() SimulationIds.STORAGE_VILLAGE_WOODPILE: storage = simulation_manager.get_woodpile() return storage.get_amount(opportunity.get_resource_id()) if storage != null else 0.0 func _get_opportunity_action_name(opportunity: OpportunityStateRecord) -> String: var trigger: EconomicEventRecord = simulation_manager.get_opportunity_trigger_event(opportunity) if trigger == null: return "work" return _get_action_name(StringName(trigger.data.get("action_id", &""))) func _get_npc_name(npc_id: int) -> String: for npc in simulation_manager.npcs: if npc.id == npc_id: return npc.npc_name return "Someone" func _get_action_name(action_id: StringName) -> String: if action_id == SimulationIds.ACTION_IDLE: return "Idle" if action_id == SimulationIds.ACTION_DEAD: return "Dead" var definition := SimulationDefinitions.get_action(action_id) if definition != null: return definition.display_name return String(action_id).capitalize() func set_debug_overlay_visible(is_visible: bool) -> void: debug_overlay_visible = is_visible visible = is_visible if is_visible: _refresh_npc_inspector() func _build_event_history(npc: SimNPC) -> String: if not simulation_manager.has_method("get_npc_events"): return "" var events: Array = simulation_manager.get_npc_events(npc.id, 3) if events.is_empty(): return "No recorded events yet" var name_map := {} for other_npc in simulation_manager.npcs: name_map[other_npc.id] = other_npc.npc_name var lines: Array[String] = [] for event in events: lines.append(event.description(name_map)) return "\n".join(lines) func _build_relationship_display(npc: SimNPC) -> String: if not simulation_manager.has_method("get_primary_relationship"): return "" var relationship: RelationshipStateRecord = simulation_manager.get_primary_relationship(npc.id) if relationship == null: return "" var other_name := "Someone" for other_npc in simulation_manager.npcs: if other_npc.id == relationship.get_subject_id(): other_name = other_npc.npc_name break var display := ( "Relationship: %s — familiar %.0f%%, trust %.0f%%" % [ other_name, relationship.get_familiarity() * 100.0, relationship.get_trust() * 100.0, ] ) if not simulation_manager.has_method("get_relationship_cause"): return display var cause: EconomicEventRecord = simulation_manager.get_relationship_cause(relationship) if cause == null: return display var name_map := {} for other_npc in simulation_manager.npcs: name_map[other_npc.id] = other_npc.npc_name return "%s\nBecause: %s" % [display, cause.description(name_map)] func _build_memory_history_display(npc: SimNPC) -> String: if not simulation_manager.has_method("get_retained_memory_events"): return "Memories unavailable" var summary: Dictionary = simulation_manager.get_memory_summary(npc.id) var header := ( "Memories %d lasting · %d recent" % [int(summary.get("lasting", 0)), int(summary.get("recent", 0))] ) var retained_events: Array = simulation_manager.get_retained_memory_events(npc.id, 4) if retained_events.is_empty(): return header + "\nNo retained memories" var name_map := {} for other_npc in simulation_manager.npcs: name_map[other_npc.id] = other_npc.npc_name var lines: Array[String] = [header] for event in retained_events: var event_id := int(event.data["event_id"]) var known_record: KnownEventStateRecord = simulation_manager.get_known_event_record( npc.id, event_id ) if known_record == null: continue var lasting: bool = simulation_manager.is_known_event_lasting(npc.id, event_id) var marker := "◆" if lasting else "•" var retention := "lasting" if lasting else "recent" ( lines . append( ( "%s %s · %s · %s" % [ marker, retention, _build_compact_memory_summary(event, name_map), _build_memory_provenance(known_record, name_map), ] ) ) ) return "\n".join(lines) func _build_compact_memory_summary(event: EconomicEventRecord, name_map: Dictionary) -> String: var event_type := StringName(event.data["event_type"]) var item_id := StringName(event.data["item_id"]) if ( event_type == SimulationIds.EVENT_STORAGE_DEPOSITED and item_id == SimulationIds.RESOURCE_FOOD ): var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone") return "%s stocked %.0f food" % [actor_name, float(event.data["amount"])] if ( event_type == SimulationIds.EVENT_STORAGE_WITHDRAWN and item_id == SimulationIds.RESOURCE_FOOD ): var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone") return "%s took %.0f food from the pantry" % [actor_name, float(event.data["amount"])] return event.description(name_map) func _build_memory_provenance(record: KnownEventStateRecord, name_map: Dictionary) -> String: match record.get_acquisition_method(): SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED: return "own action" SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED: return "witnessed" SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED: var source_name: String = name_map.get(record.get_source_npc_id(), "someone") return "heard %s" % source_name return "source unknown"