From f9601a67d1cb2cd65253bbdf01d7a0e3d55f5e59 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 5 Jul 2026 18:04:11 +0200 Subject: [PATCH] feat: add structured economic events --- player/npc/NpcVisual.gd | 8 ++ player/npc/NpcVisual.tscn | 16 +++- simulation/SimulationManager.gd | 98 +++++++++++++++++++- simulation/definitions/SimulationIds.gd | 5 + simulation/state/EconomicEventRecord.gd | 64 +++++++++++++ simulation/state/EconomicEventRecord.gd.uid | 1 + simulation/state/SimulationStateRecord.gd | 64 ++++++++++--- tests/food_storage_loop_test.gd | 57 ++++++++++++ tests/npc_visual_lifecycle_test.gd | 18 ++++ tests/simulation_state_serialization_test.gd | 19 ++++ world/world_view_manager.gd | 25 +++++ 11 files changed, 358 insertions(+), 17 deletions(-) create mode 100644 simulation/state/EconomicEventRecord.gd create mode 100644 simulation/state/EconomicEventRecord.gd.uid diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 9747562..74ddd96 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -12,6 +12,7 @@ const DEBUG_LOGS := true @export var stuck_timeout := 1.5 @onready var nav_agent: NavigationAgent3D = $NavigationAgent3D +@onready var carried_food_visual: MeshInstance3D = $CarriedFood var has_reported_arrival := false var sim_id: int = -1 @@ -41,6 +42,12 @@ func setup_from_sim(npc: SimNPC) -> void: npc_name = npc.npc_name profession = npc.profession name = "NPC_%s_%s" % [sim_id, npc_name] + set_carried_food_visible( + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0 + ) + +func set_carried_food_visible(is_visible: bool) -> void: + carried_food_visual.visible = is_visible and not is_dead_visual func set_target_position(pos: Vector3) -> void: path_request_id += 1 @@ -167,6 +174,7 @@ func _report_navigation_failure_once() -> void: func apply_dead_visual_state() -> void: is_dead_visual = true + carried_food_visual.visible = false path_request_id += 1 velocity = Vector3.ZERO current_path = PackedVector3Array() diff --git a/player/npc/NpcVisual.tscn b/player/npc/NpcVisual.tscn index 9b9f9d0..333e3de 100644 --- a/player/npc/NpcVisual.tscn +++ b/player/npc/NpcVisual.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=6 format=3 uid="uid://dhxxyprqflotq"] +[gd_scene load_steps=8 format=3 uid="uid://dhxxyprqflotq"] [ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"] @@ -12,6 +12,15 @@ albedo_color = Color(1, 0.22352941, 1, 1) [sub_resource type="BoxMesh" id="BoxMesh_d844k"] material = SubResource("StandardMaterial3D_d844k") +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"] +albedo_color = Color(0.38, 0.22, 0.08, 1) +roughness = 0.9 + +[sub_resource type="SphereMesh" id="SphereMesh_food"] +material = SubResource("StandardMaterial3D_food") +radius = 0.28 +height = 0.48 + [node name="NpcVisual" type="CharacterBody3D"] collision_layer = 2 collision_mask = 1 @@ -29,6 +38,11 @@ mesh = SubResource("CapsuleMesh_d844k") transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.35, 0, 1.2, 0.59052056) mesh = SubResource("BoxMesh_d844k") +[node name="CarriedFood" type="MeshInstance3D" parent="."] +visible = false +transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0) +mesh = SubResource("SphereMesh_food") + [node name="NavigationAgent3D" type="NavigationAgent3D" parent="."] path_desired_distance = 0.3 target_desired_distance = 0.5 diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 27dc919..c7703f8 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -9,6 +9,8 @@ signal village_changed(village: SimVillage) signal npc_died(npc: SimNPC) signal npc_target_requested(npc: SimNPC) signal npc_travel_requested(npc: SimNPC, target_position: Vector3) +signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float) +signal economic_event_recorded(event: EconomicEventRecord) var village := SimVillage.new() @@ -23,6 +25,8 @@ var tick_count := 0 var wander_random_sources := {} var resource_states: Dictionary = {} var storage_states: Dictionary = {} +var economic_events: Array[EconomicEventRecord] = [] +var next_event_id := 0 var action_selector := ActionSelectionSystem.new() var action_executor := ActionExecutionSystem.new() var target_resolver := ActionTargetResolver.new() @@ -162,11 +166,28 @@ func simulate_tick() -> void: SimulationIds.RESOURCE_FOOD, extracted ) + npc_inventory_changed.emit( + npc, + SimulationIds.RESOURCE_FOOD, + npc.get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ) + ) else: village.apply_resource_delta( resource_state.get_resource_id(), extracted ) + _record_economic_event( + SimulationIds.EVENT_RESOURCE_EXTRACTED, + npc.id, + resource_state.get_node_id(), + _npc_inventory_id(npc.id) + if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD + else &"village", + resource_state.get_resource_id(), + extracted + ) if debug_logs: print( "[SimulationManager] ", @@ -355,6 +376,33 @@ func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool: return true return false +func _npc_inventory_id(npc_id: int) -> StringName: + return StringName("npc_%d_inventory" % npc_id) + +func _record_economic_event( + event_type: StringName, + actor_id: int, + source_id: StringName, + destination_id: StringName, + item_id: StringName, + amount: float +) -> void: + if amount <= 0.0: + return + var event := EconomicEventRecord.create( + next_event_id, + event_type, + tick_count, + actor_id, + source_id, + destination_id, + item_id, + amount + ) + next_event_id += 1 + economic_events.append(event) + economic_event_recorded.emit(event) + func _initialize_storage() -> void: if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): _sync_village_food() @@ -384,6 +432,16 @@ func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float: var deposited := get_pantry().deposit(item_id, available) npc.remove_inventory(item_id, deposited) _sync_village_food() + if deposited > 0.0: + npc_inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id)) + _record_economic_event( + SimulationIds.EVENT_STORAGE_DEPOSITED, + npc.id, + _npc_inventory_id(npc.id), + SimulationIds.STORAGE_VILLAGE_PANTRY, + item_id, + deposited + ) return deposited func withdraw_to_npc( @@ -394,6 +452,16 @@ func withdraw_to_npc( var withdrawn := get_pantry().withdraw(item_id, amount) npc.add_inventory(item_id, withdrawn) _sync_village_food() + if withdrawn > 0.0: + npc_inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id)) + _record_economic_event( + SimulationIds.EVENT_STORAGE_WITHDRAWN, + npc.id, + SimulationIds.STORAGE_VILLAGE_PANTRY, + _npc_inventory_id(npc.id), + item_id, + withdrawn + ) return withdrawn func consume_npc_food(npc: SimNPC) -> bool: @@ -404,6 +472,19 @@ func consume_npc_food(npc: SimNPC) -> bool: npc.hunger = maxf(npc.hunger - 55.0, 0.0) npc.starvation_ticks = 0 npc.is_starving = npc.hunger >= 90.0 + npc_inventory_changed.emit( + npc, + SimulationIds.RESOURCE_FOOD, + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) + ) + _record_economic_event( + SimulationIds.EVENT_ITEM_CONSUMED, + npc.id, + _npc_inventory_id(npc.id), + &"consumed", + SimulationIds.RESOURCE_FOOD, + 1.0 + ) return true func register_loaded_resource_nodes() -> void: @@ -484,6 +565,16 @@ func harvest_resource_node(node: ResourceNode) -> float: _sync_village_food() else: village.apply_resource_delta(resource_state.get_resource_id(), extracted) + _record_economic_event( + SimulationIds.EVENT_RESOURCE_EXTRACTED, + -1, + resource_state.get_node_id(), + SimulationIds.STORAGE_VILLAGE_PANTRY + if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD + else &"village", + resource_state.get_resource_id(), + extracted + ) village_changed.emit(village) if debug_logs: @@ -591,7 +682,8 @@ func create_state_record() -> SimulationStateRecord: "tick_count": tick_count, "clock_accumulator": clock.accumulator, "clock_elapsed_ticks": clock.elapsed_ticks, - "wander_random_streams": wander_streams + "wander_random_streams": wander_streams, + "next_event_id": next_event_id } record.village = VillageStateRecord.capture(village) for npc in npcs: @@ -607,6 +699,8 @@ func create_state_record() -> SimulationStateRecord: for storage_id in sorted_storage_ids: var storage_state: StorageStateRecord = storage_states[storage_id] record.storages.append(storage_state) + for event in economic_events: + record.economic_events.append(event) return record func serialize_state() -> String: @@ -626,6 +720,7 @@ func restore_state(record: SimulationStateRecord) -> bool: simulation_seed = int(record.simulation["seed"]) tick_interval = float(record.simulation["tick_interval"]) tick_count = int(record.simulation["tick_count"]) + next_event_id = int(record.simulation["next_event_id"]) clock = SimulationClock.new(tick_interval) clock.accumulator = float(record.simulation["clock_accumulator"]) clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"]) @@ -634,6 +729,7 @@ func restore_state(record: SimulationStateRecord) -> bool: for storage_record in record.storages: storage_states[storage_record.get_storage_id()] = storage_record _sync_village_food() + economic_events = record.economic_events.duplicate() npcs.clear() for npc_record in record.npcs: diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 9c016db..29fc102 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -27,3 +27,8 @@ const RESOURCE_FOOD := &"food" const RESOURCE_WOOD := &"wood" const STORAGE_VILLAGE_PANTRY := &"village_pantry" + +const EVENT_RESOURCE_EXTRACTED := &"resource_extracted" +const EVENT_STORAGE_DEPOSITED := &"storage_deposited" +const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn" +const EVENT_ITEM_CONSUMED := &"item_consumed" diff --git a/simulation/state/EconomicEventRecord.gd b/simulation/state/EconomicEventRecord.gd new file mode 100644 index 0000000..8bcbd22 --- /dev/null +++ b/simulation/state/EconomicEventRecord.gd @@ -0,0 +1,64 @@ +class_name EconomicEventRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + +static func create( + event_id: int, + event_type: StringName, + tick: int, + actor_id: int, + source_id: StringName, + destination_id: StringName, + item_id: StringName, + amount: float +) -> EconomicEventRecord: + return EconomicEventRecord.new({ + "schema_version": SCHEMA_VERSION, + "event_id": event_id, + "event_type": String(event_type), + "tick": tick, + "actor_id": actor_id, + "source_id": String(source_id), + "destination_id": String(destination_id), + "item_id": String(item_id), + "amount": amount + }) + +static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all([ + "event_id", "event_type", "tick", "actor_id", "source_id", + "destination_id", "item_id", "amount" + ]): + return null + var normalized := record_data.duplicate(true) + normalized["schema_version"] = SCHEMA_VERSION + normalized["event_id"] = int(record_data["event_id"]) + normalized["event_type"] = String(record_data["event_type"]) + normalized["tick"] = int(record_data["tick"]) + normalized["actor_id"] = int(record_data["actor_id"]) + normalized["source_id"] = String(record_data["source_id"]) + normalized["destination_id"] = String(record_data["destination_id"]) + normalized["item_id"] = String(record_data["item_id"]) + normalized["amount"] = float(record_data["amount"]) + if ( + normalized["event_id"] < 0 + or normalized["tick"] < 0 + or normalized["event_type"].is_empty() + or normalized["source_id"].is_empty() + or normalized["destination_id"].is_empty() + or normalized["item_id"].is_empty() + or normalized["amount"] <= 0.0 + ): + return null + return EconomicEventRecord.new(normalized) + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/EconomicEventRecord.gd.uid b/simulation/state/EconomicEventRecord.gd.uid new file mode 100644 index 0000000..a39f026 --- /dev/null +++ b/simulation/state/EconomicEventRecord.gd.uid @@ -0,0 +1 @@ +uid://cbj70dfuif0uw diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 374b7eb..15e2e79 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,14 +2,16 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 2 +const SCHEMA_VERSION := 3 const LEGACY_SCHEMA_VERSION := 1 +const PREVIOUS_SCHEMA_VERSION := 2 var simulation: Dictionary var village: VillageStateRecord var npcs: Array[NPCStateRecord] = [] var resources: Array[ResourceStateRecord] = [] var storages: Array[StorageStateRecord] = [] +var economic_events: Array[EconomicEventRecord] = [] func to_dictionary() -> Dictionary: var npc_data: Array[Dictionary] = [] @@ -22,6 +24,9 @@ func to_dictionary() -> Dictionary: var storage_data: Array[Dictionary] = [] for storage_record in storages: storage_data.append(storage_record.to_dictionary()) + var event_data: Array[Dictionary] = [] + for event_record in economic_events: + event_data.append(event_record.to_dictionary()) return { "schema": SCHEMA_NAME, @@ -30,7 +35,8 @@ func to_dictionary() -> Dictionary: "village": village.to_dictionary(), "npcs": npc_data, "resources": resource_data, - "storages": storage_data + "storages": storage_data, + "economic_events": event_data } func to_json() -> String: @@ -46,12 +52,13 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if record_data.get("schema", "") != SCHEMA_NAME: return null var version := int(record_data.get("schema_version", -1)) - if version == LEGACY_SCHEMA_VERSION: - record_data = _migrate_v1(record_data) + if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]: + record_data = _migrate_legacy(record_data, version) elif version != SCHEMA_VERSION: return null if not record_data.has_all([ - "simulation", "village", "npcs", "resources", "storages" + "simulation", "village", "npcs", "resources", "storages", + "economic_events" ]): return null @@ -60,7 +67,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null if not simulation_data.has_all([ "seed", "tick_interval", "tick_count", "clock_accumulator", - "clock_elapsed_ticks", "wander_random_streams" + "clock_elapsed_ticks", "wander_random_streams", "next_event_id" ]): return null var wander_streams = simulation_data["wander_random_streams"] @@ -82,10 +89,12 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var npc_data = record_data["npcs"] var resource_data = record_data["resources"] var storage_data = record_data["storages"] + var event_data = record_data["economic_events"] if ( not npc_data is Array or not resource_data is Array or not storage_data is Array + or not event_data is Array ): return null @@ -132,17 +141,42 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: storage_ids[storage_id] = true record.storages.append(storage_record) + var event_ids := {} + var highest_event_id := -1 + for item in event_data: + if not item is Dictionary: + return null + var event_record := EconomicEventRecord.from_dictionary(item) + if event_record == null: + return null + var event_id := int(event_record.data["event_id"]) + if event_ids.has(event_id): + return null + event_ids[event_id] = true + highest_event_id = maxi(highest_event_id, event_id) + record.economic_events.append(event_record) + if int(record.simulation["next_event_id"]) <= highest_event_id: + return null + return record -static func _migrate_v1(legacy_data: Dictionary) -> Dictionary: +static func _migrate_legacy( + legacy_data: Dictionary, + version: int +) -> Dictionary: var migrated := legacy_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION - var village_data: Dictionary = legacy_data.get("village", {}) - var initial_food := float(village_data.get("food", 0.0)) - migrated["storages"] = [ - StorageStateRecord.create( - SimulationIds.STORAGE_VILLAGE_PANTRY, - {String(SimulationIds.RESOURCE_FOOD): initial_food} - ).to_dictionary() - ] + if version == LEGACY_SCHEMA_VERSION: + var village_data: Dictionary = legacy_data.get("village", {}) + var initial_food := float(village_data.get("food", 0.0)) + migrated["storages"] = [ + StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, + {String(SimulationIds.RESOURCE_FOOD): initial_food} + ).to_dictionary() + ] + migrated["economic_events"] = [] + var simulation_data: Dictionary = migrated.get("simulation", {}) + simulation_data["next_event_id"] = 0 + migrated["simulation"] = simulation_data return migrated diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index 3dcce1a..95e0822 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -110,6 +110,21 @@ func _run() -> void: is_equal_approx(conserved_food, 9.0), "Exactly one food unit should leave the world when eaten" ) + _check_economic_event_chain(manager) + + var restored: Node = load("res://simulation/SimulationManager.gd").new() + restored.debug_logs = false + root.add_child(restored) + restored.set_process(false) + _check( + restored.restore_state_from_json(manager.serialize_state()), + "Economic events should restore with the simulation" + ) + _check( + restored.economic_events.size() == manager.economic_events.size() + and restored.next_event_id == manager.next_event_id, + "Economic event history and its next ID should round-trip" + ) if failures.is_empty(): print("[TEST] Food storage loop passed") @@ -122,3 +137,45 @@ func _run() -> void: func _check(condition: bool, message: String) -> void: if not condition: failures.append(message) + +func _check_economic_event_chain(manager: Node) -> void: + var expected_types: Array[StringName] = [ + SimulationIds.EVENT_RESOURCE_EXTRACTED, + SimulationIds.EVENT_STORAGE_DEPOSITED, + SimulationIds.EVENT_STORAGE_WITHDRAWN, + SimulationIds.EVENT_ITEM_CONSUMED + ] + _check( + manager.economic_events.size() == expected_types.size(), + "Food transfer loop should emit one structured event per transfer" + ) + if manager.economic_events.size() != expected_types.size(): + return + for index in expected_types.size(): + var event: EconomicEventRecord = manager.economic_events[index] + _check( + int(event.data["event_id"]) == index + and StringName(event.data["event_type"]) == expected_types[index] + and StringName(event.data["item_id"]) + == SimulationIds.RESOURCE_FOOD, + "Economic event order, identity, and item should be deterministic" + ) + _check( + is_equal_approx( + float(manager.economic_events[0].data["amount"]), + 2.0 + ) + and is_equal_approx( + float(manager.economic_events[1].data["amount"]), + 2.0 + ) + and is_equal_approx( + float(manager.economic_events[2].data["amount"]), + 1.0 + ) + and is_equal_approx( + float(manager.economic_events[3].data["amount"]), + 1.0 + ), + "Economic events should record exact transferred quantities" + ) diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd index eafed1e..26cafc5 100644 --- a/tests/npc_visual_lifecycle_test.gd +++ b/tests/npc_visual_lifecycle_test.gd @@ -18,6 +18,24 @@ func _run() -> void: var npc: SimNPC = manager.npcs[0] var visual: Node3D = view.active_npc_visuals[npc.id] visual.set_physics_process(false) + var carried_food: MeshInstance3D = visual.get_node("CarriedFood") + _check(not carried_food.visible, "NPC should begin without a carried-food prop") + npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + manager.emit_signal( + "npc_inventory_changed", + npc, + SimulationIds.RESOURCE_FOOD, + 1.0 + ) + _check(carried_food.visible, "Carried food should be visible on the NPC") + npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + manager.emit_signal( + "npc_inventory_changed", + npc, + SimulationIds.RESOURCE_FOOD, + 0.0 + ) + _check(not carried_food.visible, "Deposited or eaten food should be hidden") var resource_state: ResourceStateRecord = manager.get_resource_state( &"berry_bush_01" diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index a4e9b5e..6dc7e2f 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -11,6 +11,7 @@ func _run() -> void: _test_legacy_npc_migration() _test_legacy_resource_migration() _test_legacy_world_storage_migration() + _test_previous_world_event_migration() _test_schema_rejection() if failures.is_empty(): @@ -203,6 +204,24 @@ func _test_legacy_world_storage_migration() -> void: ) manager.free() +func _test_previous_world_event_migration() -> void: + var manager := _create_manager(56) + var previous_data: Dictionary = manager.create_state_record().to_dictionary() + previous_data["schema_version"] = ( + SimulationStateRecord.PREVIOUS_SCHEMA_VERSION + ) + previous_data.erase("economic_events") + previous_data["simulation"].erase("next_event_id") + var migrated := SimulationStateRecord.from_dictionary(previous_data) + _check(migrated != null, "World schema v2 should add an empty event stream") + if migrated != null: + _check( + migrated.economic_events.is_empty() + and int(migrated.simulation["next_event_id"]) == 0, + "World v2 migration should initialize deterministic event identity" + ) + manager.free() + func _create_manager(seed_value: int) -> Node: var manager: Node = load("res://simulation/SimulationManager.gd").new() manager.simulation_seed = seed_value diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index e57bc97..3aa39be 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -33,6 +33,14 @@ func initialize_world_view() -> void: simulation_manager.npc_died.connect(_on_npc_died) else: push_error("WorldViewManager: SimulationManager has no npc_died signal") + if simulation_manager.has_signal("npc_inventory_changed"): + simulation_manager.npc_inventory_changed.connect( + _on_npc_inventory_changed + ) + else: + push_error( + "WorldViewManager: SimulationManager has no npc_inventory_changed signal" + ) spawn_initial_npcs() func spawn_initial_npcs() -> void: @@ -155,3 +163,20 @@ func _on_npc_died(npc: SimNPC) -> void: visual.apply_dead_visual_state() else: push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state") + +func _on_npc_inventory_changed( + npc: SimNPC, + item_id: StringName, + amount: float +) -> void: + if item_id != SimulationIds.RESOURCE_FOOD: + return + var visual = active_npc_visuals.get(npc.id) + if visual == null: + return + if visual.has_method("set_carried_food_visible"): + visual.set_carried_food_visible(amount > 0.0) + else: + push_error( + "WorldViewManager: NPCVisual has no set_carried_food_visible method" + )