diff --git a/main.tscn b/main.tscn index cc87c74..faf5efb 100644 --- a/main.tscn +++ b/main.tscn @@ -70,12 +70,12 @@ skeleton = NodePath("../../..") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.18380833, 0) shape = SubResource("BoxShape3D_lquwl") -[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "food_zone", "guard_zone", "study_zone")] +[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "pantry_marker", "guard_zone", "study_zone")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0) script = ExtResource("1_h2yge") camera_rig = NodePath("../CameraRig") simulation_manager = NodePath("../SimulationManager") -food_zone = NodePath("../ActivityMarkers/FoodZone") +pantry_marker = NodePath("../ActivityMarkers/PantryMarker") guard_zone = NodePath("../ActivityMarkers/GuardZone") study_zone = NodePath("../ActivityMarkers/StudyZone") stomach_capacity_for_food = 10 @@ -104,12 +104,12 @@ deadzone_forward = 2.0 current = true fov = 65.0 -[node name="ActiveWorldAdapter" type="Node" parent="." node_paths=PackedStringArray("guard_marker", "study_marker", "rest_marker", "food_marker")] +[node name="ActiveWorldAdapter" type="Node" parent="." node_paths=PackedStringArray("guard_marker", "study_marker", "rest_marker", "pantry_marker")] script = ExtResource("9_adapter") guard_marker = NodePath("../ActivityMarkers/GuardZone") study_marker = NodePath("../ActivityMarkers/StudyZone") rest_marker = NodePath("../ActivityMarkers/RestZone") -food_marker = NodePath("../ActivityMarkers/FoodZone") +pantry_marker = NodePath("../ActivityMarkers/PantryMarker") [node name="SimulationManager" type="Node" parent="." unique_id=1099676814 node_paths=PackedStringArray("active_world_adapter")] script = ExtResource("3_lquwl") @@ -156,10 +156,10 @@ mesh = SubResource("PrismMesh_5vw27") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3897309, 1.399735, 1.0352364) mesh = SubResource("PrismMesh_5vw27") -[node name="FoodZone" type="Marker3D" parent="ActivityMarkers" unique_id=780921054] +[node name="PantryMarker" type="Marker3D" parent="ActivityMarkers" unique_id=780921054] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -8) -[node name="MeshInstance3D" type="MeshInstance3D" parent="ActivityMarkers/FoodZone" unique_id=357423915] +[node name="MeshInstance3D" type="MeshInstance3D" parent="ActivityMarkers/PantryMarker" unique_id=357423915] transform = Transform3D(4, 0, 0, 0, 3, 0, 0, 0, 4, 0, 1.5, 0) mesh = SubResource("BoxMesh_4c57u") diff --git a/player/player.gd b/player/player.gd index cc1bb60..b4ba4bd 100644 --- a/player/player.gd +++ b/player/player.gd @@ -7,7 +7,7 @@ extends CharacterBody3D @export var simulation_manager: Node -@export var food_zone: Marker3D +@export var pantry_marker: Marker3D @export var guard_zone: Marker3D @export var study_zone: Marker3D @@ -69,7 +69,7 @@ func try_interact() -> void: elif is_near(study_zone): simulation_manager.add_knowledge(2.0) print("Player shared knowledge.") - elif is_near(food_zone): + elif is_near(pantry_marker): simulation_manager.eat_food(stomach_capacity_for_food) print("Player ate food from the village supply.") diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 2f1559c..7624480 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -28,6 +28,7 @@ var task_complete := true var target_id: StringName = &"" var travel_target_position: Vector3 var has_travel_target := false +var inventory: Dictionary = {} var last_task: StringName var random_source: RandomNumberGenerator var debug_logs := true @@ -69,12 +70,19 @@ func die_from_starvation() -> void: if debug_logs: print("[SimNPC] ", npc_name, " died from starvation.") -func should_eat_immediately_after_task() -> bool: - return ( - current_task == SimulationIds.ACTION_GATHER_FOOD - and is_starving - and not is_dead +func get_inventory_amount(item_id: StringName) -> float: + return float(inventory.get(String(item_id), 0.0)) + +func add_inventory(item_id: StringName, amount: float) -> void: + inventory[String(item_id)] = get_inventory_amount(item_id) + maxf(amount, 0.0) + +func remove_inventory(item_id: StringName, requested_amount: float) -> float: + var removed := minf( + maxf(requested_amount, 0.0), + get_inventory_amount(item_id) ) + inventory[String(item_id)] = get_inventory_amount(item_id) - removed + return removed func set_task(action_id: StringName, duration: float = -1.0) -> void: var definition := SimulationDefinitions.get_action(action_id) diff --git a/simulation/SimVillage.gd b/simulation/SimVillage.gd index 5cbd1a9..a516f6f 100644 --- a/simulation/SimVillage.gd +++ b/simulation/SimVillage.gd @@ -101,23 +101,10 @@ func apply_npc_task(npc: SimNPC) -> void: productivity = 0.5 match npc.current_task: - SimulationIds.ACTION_GATHER_FOOD: - food += 2.0 * productivity - SimulationIds.ACTION_GATHER_WOOD: - wood += 2.0 * productivity SimulationIds.ACTION_PATROL: safety += 1.0 * productivity SimulationIds.ACTION_STUDY: knowledge += 1.0 * productivity - SimulationIds.ACTION_EAT: - if food > 0: - food -= 1.0 - npc.hunger = max(npc.hunger - 55.0, 0.0) - npc.starvation_ticks = 0 - npc.is_starving = npc.hunger >= 90.0 - else: - npc.hunger = min(npc.hunger + 5.0, 100.0) - npc.is_starving = npc.hunger >= 90.0 SimulationIds.ACTION_REST: if npc.is_starving: npc.energy = min(npc.energy + 2.0, 100.0) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index db28b1f..27dc919 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -22,6 +22,7 @@ var clock: SimulationClock var tick_count := 0 var wander_random_sources := {} var resource_states: Dictionary = {} +var storage_states: Dictionary = {} var action_selector := ActionSelectionSystem.new() var action_executor := ActionExecutionSystem.new() var target_resolver := ActionTargetResolver.new() @@ -40,6 +41,7 @@ func _ready() -> void: return clock = SimulationClock.new(tick_interval) village.debug_logs = debug_logs + _initialize_storage() village.update_modifiers() village.update_priorities() generate_npcs() @@ -140,9 +142,14 @@ func simulate_tick() -> void: if not was_complete and npc.task_complete and not npc.is_dead: var completed_task := npc.current_task - var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task() - - if npc.target_id != &"": + var completed_definition := SimulationDefinitions.get_action( + completed_task + ) + if ( + npc.target_id != &"" + and completed_definition != null + and completed_definition.target_type == SimulationIds.TARGET_RESOURCE + ): var resource_state := get_resource_state(npc.target_id) if ( resource_state != null @@ -150,10 +157,16 @@ func simulate_tick() -> void: ): var extracted := resource_state.extract() if extracted > 0: - village.apply_resource_delta( - resource_state.get_resource_id(), - extracted - ) + if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD: + npc.add_inventory( + SimulationIds.RESOURCE_FOOD, + extracted + ) + else: + village.apply_resource_delta( + resource_state.get_resource_id(), + extracted + ) if debug_logs: print( "[SimulationManager] ", @@ -168,6 +181,12 @@ func simulate_tick() -> void: elif debug_logs: print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid") release_npc_reservation(npc.id) + elif completed_task == SimulationIds.ACTION_DEPOSIT_FOOD: + deposit_npc_inventory(npc, SimulationIds.RESOURCE_FOOD) + elif completed_task == SimulationIds.ACTION_WITHDRAW_FOOD: + withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0) + elif completed_task == SimulationIds.ACTION_EAT: + consume_npc_food(npc) elif completed_task not in [ SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD @@ -183,14 +202,7 @@ func simulate_tick() -> void: npc.last_task = completed_task npc.current_task = SimulationIds.ACTION_IDLE npc.has_travel_target = false - - if needs_immediate_food_after_gather and village.food > 0: - npc.set_task(SimulationIds.ACTION_EAT, 1.0) - npc_task_changed.emit(npc, completed_task, npc.current_task) - npc_target_requested.emit(npc) - - if debug_logs: - print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.") + npc.target_id = &"" if debug_logs: print( @@ -251,7 +263,14 @@ func notify_npc_arrived(npc_id: int) -> void: return if npc.task_state == SimNPC.TASK_STATE_TRAVELING: - if npc.target_id != &"": + var definition := SimulationDefinitions.get_action( + npc.current_task + ) + if ( + npc.target_id != &"" + and definition != null + and definition.target_type == SimulationIds.TARGET_RESOURCE + ): var resource_state := get_resource_state(npc.target_id) if ( resource_state == null @@ -336,6 +355,57 @@ func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool: return true return false +func _initialize_storage() -> void: + if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): + _sync_village_food() + return + storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = ( + StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, + {String(SimulationIds.RESOURCE_FOOD): village.food} + ) + ) + _sync_village_food() + +func get_pantry() -> StorageStateRecord: + return storage_states.get( + SimulationIds.STORAGE_VILLAGE_PANTRY + ) as StorageStateRecord + +func _sync_village_food() -> void: + var pantry := get_pantry() + if pantry != null: + village.food = pantry.get_amount(SimulationIds.RESOURCE_FOOD) + village.update_modifiers() + village.update_priorities() + +func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float: + var available := npc.get_inventory_amount(item_id) + var deposited := get_pantry().deposit(item_id, available) + npc.remove_inventory(item_id, deposited) + _sync_village_food() + return deposited + +func withdraw_to_npc( + npc: SimNPC, + item_id: StringName, + amount: float +) -> float: + var withdrawn := get_pantry().withdraw(item_id, amount) + npc.add_inventory(item_id, withdrawn) + _sync_village_food() + return withdrawn + +func consume_npc_food(npc: SimNPC) -> bool: + if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0: + npc.hunger = minf(npc.hunger + 5.0, 100.0) + npc.is_starving = npc.hunger >= 90.0 + return false + npc.hunger = maxf(npc.hunger - 55.0, 0.0) + npc.starvation_ticks = 0 + npc.is_starving = npc.hunger >= 90.0 + return true + func register_loaded_resource_nodes() -> void: for node in ResourceNode.get_all(): register_resource_node(node) @@ -409,7 +479,11 @@ func harvest_resource_node(node: ResourceNode) -> float: if extracted <= 0.0: return 0.0 - village.apply_resource_delta(resource_state.get_resource_id(), extracted) + if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD: + get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted) + _sync_village_food() + else: + village.apply_resource_delta(resource_state.get_resource_id(), extracted) village_changed.emit(village) if debug_logs: @@ -425,11 +499,16 @@ func harvest_resource_node(node: ResourceNode) -> float: return extracted func eat_food(amount: float) -> void: - village.apply_resource_delta(&"food", -amount) + get_pantry().withdraw(SimulationIds.RESOURCE_FOOD, amount) + _sync_village_food() village_changed.emit(village) func add_food(amount: float) -> void: - village.apply_resource_delta(&"food", amount) + if amount >= 0.0: + get_pantry().deposit(SimulationIds.RESOURCE_FOOD, amount) + else: + get_pantry().withdraw(SimulationIds.RESOURCE_FOOD, -amount) + _sync_village_food() village_changed.emit(village) func add_wood(amount: float) -> void: @@ -523,6 +602,11 @@ func create_state_record() -> SimulationStateRecord: for resource_id in sorted_resource_ids: var resource_state: ResourceStateRecord = resource_states[resource_id] record.resources.append(resource_state) + var sorted_storage_ids: Array = storage_states.keys() + sorted_storage_ids.sort() + for storage_id in sorted_storage_ids: + var storage_state: StorageStateRecord = storage_states[storage_id] + record.storages.append(storage_state) return record func serialize_state() -> String: @@ -546,6 +630,10 @@ func restore_state(record: SimulationStateRecord) -> bool: clock.accumulator = float(record.simulation["clock_accumulator"]) clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"]) village = record.village.restore(debug_logs) + storage_states.clear() + for storage_record in record.storages: + storage_states[storage_record.get_storage_id()] = storage_record + _sync_village_food() npcs.clear() for npc_record in record.npcs: diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 811d7ec..e6924c0 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -5,13 +5,22 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: if npc.is_dead: return null if npc.is_starving: - if village.food > 0: + if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new(SimulationIds.ACTION_EAT, 1.0) + if village.food > 0: + return ActionSelectionResult.new( + SimulationIds.ACTION_WITHDRAW_FOOD, + 1.0 + ) return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD, 4.0) if npc.hunger > 75.0: - if village.food > 0: + if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new(SimulationIds.ACTION_EAT) + if village.food > 0: + return ActionSelectionResult.new(SimulationIds.ACTION_WITHDRAW_FOOD) return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD) + if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0: + return ActionSelectionResult.new(SimulationIds.ACTION_DEPOSIT_FOOD) if npc.energy < 25.0: return ActionSelectionResult.new(SimulationIds.ACTION_REST) return ActionSelectionResult.new(_choose_best_work_action(npc, village)) diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd index bdb3bae..8784d6f 100644 --- a/simulation/definitions/SimulationDefinitions.gd +++ b/simulation/definitions/SimulationDefinitions.gd @@ -8,7 +8,9 @@ const ACTION_PATHS := [ "res://simulation/definitions/actions/study.tres", "res://simulation/definitions/actions/eat.tres", "res://simulation/definitions/actions/rest.tres", - "res://simulation/definitions/actions/wander.tres" + "res://simulation/definitions/actions/wander.tres", + "res://simulation/definitions/actions/deposit_food.tres", + "res://simulation/definitions/actions/withdraw_food.tres" ] const PROFESSION_PATHS := [ diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index d4baa6e..9c016db 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -10,6 +10,8 @@ const ACTION_STUDY := &"study" const ACTION_EAT := &"eat" const ACTION_REST := &"rest" const ACTION_WANDER := &"wander" +const ACTION_DEPOSIT_FOOD := &"deposit_food" +const ACTION_WITHDRAW_FOOD := &"withdraw_food" const PROFESSION_FARMER := &"farmer" const PROFESSION_WOODCUTTER := &"woodcutter" @@ -23,3 +25,5 @@ const TARGET_FREE := &"free" const RESOURCE_FOOD := &"food" const RESOURCE_WOOD := &"wood" + +const STORAGE_VILLAGE_PANTRY := &"village_pantry" diff --git a/simulation/definitions/actions/deposit_food.tres b/simulation/definitions/actions/deposit_food.tres new file mode 100644 index 0000000..d047f77 --- /dev/null +++ b/simulation/definitions/actions/deposit_food.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +action_id = &"deposit_food" +display_name = "Deposit Food" +default_duration = 1.0 +target_type = &"activity" diff --git a/simulation/definitions/actions/withdraw_food.tres b/simulation/definitions/actions/withdraw_food.tres new file mode 100644 index 0000000..e3bd841 --- /dev/null +++ b/simulation/definitions/actions/withdraw_food.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +action_id = &"withdraw_food" +display_name = "Withdraw Food" +default_duration = 1.0 +target_type = &"activity" diff --git a/simulation/state/NPCStateRecord.gd b/simulation/state/NPCStateRecord.gd index dcb6059..42376df 100644 --- a/simulation/state/NPCStateRecord.gd +++ b/simulation/state/NPCStateRecord.gd @@ -1,8 +1,9 @@ class_name NPCStateRecord extends RefCounted -const SCHEMA_VERSION := 2 +const SCHEMA_VERSION := 3 const LEGACY_SCHEMA_VERSION := 1 +const PREVIOUS_SCHEMA_VERSION := 2 var data: Dictionary @@ -36,6 +37,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord: npc.travel_target_position.z ], "has_travel_target": npc.has_travel_target, + "inventory": npc.inventory.duplicate(true), "last_task": String(npc.last_task), "random_seed": str(npc.random_source.seed), "random_state": str(npc.random_source.state) @@ -43,8 +45,8 @@ static func capture(npc: SimNPC) -> NPCStateRecord: static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: 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([ @@ -52,7 +54,7 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: "intelligence", "current_task", "task_state", "position", "is_starving", "starvation_ticks", "starvation_death_threshold", "is_dead", "task_duration", "task_progress", "task_complete", - "target_id", "travel_target_position", "has_travel_target", + "target_id", "travel_target_position", "has_travel_target", "inventory", "last_task", "random_seed", "random_state" ]): return null @@ -65,6 +67,8 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: or saved_target_position.size() != 3 ): return null + if not record_data["inventory"] is Dictionary: + return null var profession_id := StringName(record_data["profession"]) if SimulationDefinitions.get_profession(profession_id) == null: return null @@ -85,14 +89,19 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: return null return NPCStateRecord.new(record_data) -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 - migrated["travel_target_position"] = legacy_data.get( - "position", - [0.0, 0.0, 0.0] - ) - migrated["has_travel_target"] = false + if version == LEGACY_SCHEMA_VERSION: + migrated["travel_target_position"] = legacy_data.get( + "position", + [0.0, 0.0, 0.0] + ) + migrated["has_travel_target"] = false + migrated["inventory"] = {} return migrated func restore(debug_logs: bool) -> SimNPC: @@ -131,6 +140,7 @@ func restore(debug_logs: bool) -> SimNPC: float(saved_target_position[2]) ) npc.has_travel_target = bool(data["has_travel_target"]) + npc.inventory = data["inventory"].duplicate(true) npc.last_task = StringName(data["last_task"]) npc.random_source.state = String(data["random_state"]).to_int() npc.debug_logs = debug_logs diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index b03ba09..374b7eb 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,12 +2,14 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 1 +const SCHEMA_VERSION := 2 +const LEGACY_SCHEMA_VERSION := 1 var simulation: Dictionary var village: VillageStateRecord var npcs: Array[NPCStateRecord] = [] var resources: Array[ResourceStateRecord] = [] +var storages: Array[StorageStateRecord] = [] func to_dictionary() -> Dictionary: var npc_data: Array[Dictionary] = [] @@ -17,6 +19,9 @@ func to_dictionary() -> Dictionary: var resource_data: Array[Dictionary] = [] for resource_record in resources: resource_data.append(resource_record.to_dictionary()) + var storage_data: Array[Dictionary] = [] + for storage_record in storages: + storage_data.append(storage_record.to_dictionary()) return { "schema": SCHEMA_NAME, @@ -24,7 +29,8 @@ func to_dictionary() -> Dictionary: "simulation": simulation.duplicate(true), "village": village.to_dictionary(), "npcs": npc_data, - "resources": resource_data + "resources": resource_data, + "storages": storage_data } func to_json() -> String: @@ -39,10 +45,13 @@ static func from_json(json_text: String) -> SimulationStateRecord: static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if record_data.get("schema", "") != SCHEMA_NAME: return null - if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + var version := int(record_data.get("schema_version", -1)) + if version == LEGACY_SCHEMA_VERSION: + record_data = _migrate_v1(record_data) + elif version != SCHEMA_VERSION: return null if not record_data.has_all([ - "simulation", "village", "npcs", "resources" + "simulation", "village", "npcs", "resources", "storages" ]): return null @@ -72,7 +81,12 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var npc_data = record_data["npcs"] var resource_data = record_data["resources"] - if not npc_data is Array or not resource_data is Array: + var storage_data = record_data["storages"] + if ( + not npc_data is Array + or not resource_data is Array + or not storage_data is Array + ): return null var record := SimulationStateRecord.new() @@ -105,4 +119,30 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: resource_ids[resource_id] = true record.resources.append(resource_record) + var storage_ids := {} + for item in storage_data: + if not item is Dictionary: + return null + var storage_record := StorageStateRecord.from_dictionary(item) + if storage_record == null: + return null + var storage_id := storage_record.get_storage_id() + if storage_ids.has(storage_id): + return null + storage_ids[storage_id] = true + record.storages.append(storage_record) + return record + +static func _migrate_v1(legacy_data: Dictionary) -> 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() + ] + return migrated diff --git a/simulation/state/StorageStateRecord.gd b/simulation/state/StorageStateRecord.gd new file mode 100644 index 0000000..6d6db49 --- /dev/null +++ b/simulation/state/StorageStateRecord.gd @@ -0,0 +1,73 @@ +class_name StorageStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + +static func create( + storage_id: StringName, + initial_amounts: Dictionary, + capacity: float = 100.0 +) -> StorageStateRecord: + return StorageStateRecord.new({ + "schema_version": SCHEMA_VERSION, + "storage_id": String(storage_id), + "amounts": initial_amounts.duplicate(true), + "capacity": capacity + }) + +static func from_dictionary(record_data: Dictionary) -> StorageStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all(["storage_id", "amounts", "capacity"]): + return null + if String(record_data["storage_id"]).is_empty(): + return null + if not record_data["amounts"] is Dictionary: + return null + var normalized := record_data.duplicate(true) + normalized["schema_version"] = SCHEMA_VERSION + normalized["capacity"] = float(record_data["capacity"]) + var normalized_amounts := {} + for item_id in record_data["amounts"]: + normalized_amounts[String(item_id)] = float( + record_data["amounts"][item_id] + ) + normalized["amounts"] = normalized_amounts + return StorageStateRecord.new(normalized) + +func get_storage_id() -> StringName: + return StringName(data["storage_id"]) + +func get_amount(item_id: StringName) -> float: + return float(data["amounts"].get(String(item_id), 0.0)) + +func get_total_amount() -> float: + var total := 0.0 + for amount in data["amounts"].values(): + total += float(amount) + return total + +func deposit(item_id: StringName, requested_amount: float) -> float: + var accepted := minf( + maxf(requested_amount, 0.0), + maxf(float(data["capacity"]) - get_total_amount(), 0.0) + ) + if accepted <= 0.0: + return 0.0 + data["amounts"][String(item_id)] = get_amount(item_id) + accepted + return accepted + +func withdraw(item_id: StringName, requested_amount: float) -> float: + var removed := minf(maxf(requested_amount, 0.0), get_amount(item_id)) + if removed <= 0.0: + return 0.0 + data["amounts"][String(item_id)] = get_amount(item_id) - removed + return removed + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/StorageStateRecord.gd.uid b/simulation/state/StorageStateRecord.gd.uid new file mode 100644 index 0000000..7f706fd --- /dev/null +++ b/simulation/state/StorageStateRecord.gd.uid @@ -0,0 +1 @@ +uid://cc5uvn2eengiw diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index dd79a67..72537ac 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -51,8 +51,8 @@ func _test_selection_and_execution_are_separate() -> void: var selector := ActionSelectionSystem.new() var selection := selector.select_action(npc, village) _check( - selection.action_id == SimulationIds.ACTION_EAT, - "Selection should independently choose the urgent eat action" + selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD, + "Selection should independently choose food retrieval" ) func _test_target_resolution_and_travel_are_separate() -> void: diff --git a/tests/flat_map_baseline_test.gd b/tests/flat_map_baseline_test.gd index efdcdd5..84e648e 100644 --- a/tests/flat_map_baseline_test.gd +++ b/tests/flat_map_baseline_test.gd @@ -28,7 +28,7 @@ func _run() -> void: ] var destinations: Array[Vector3] = [] for marker_path in [ - "ActivityMarkers/FoodZone", + "ActivityMarkers/PantryMarker", "ActivityMarkers/GuardZone", "ActivityMarkers/StudyZone", "ActivityMarkers/RestZone" diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd new file mode 100644 index 0000000..3dcce1a --- /dev/null +++ b/tests/food_storage_loop_test.gd @@ -0,0 +1,124 @@ +extends SceneTree + +var failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.debug_logs = false + root.add_child(manager) + manager.set_process(false) + + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0) + manager._sync_village_food() + var resource := ResourceStateRecord.from_dictionary({ + "schema_version": ResourceStateRecord.SCHEMA_VERSION, + "node_id": "food_loop_bush", + "action_id": String(SimulationIds.ACTION_GATHER_FOOD), + "resource_id": String(SimulationIds.RESOURCE_FOOD), + "amount_remaining": 10.0, + "yield_per_action": 2.0, + "reserved_by": -1, + "enabled": true, + "can_npcs_use": true, + "can_player_use": true + }) + manager.resource_states[resource.get_node_id()] = resource + + var npc: SimNPC = manager.npcs[0] + npc.hunger = 20.0 + npc.energy = 100.0 + npc.set_task(SimulationIds.ACTION_GATHER_FOOD, 1.0) + npc.target_id = resource.get_node_id() + resource.reserve(npc.id) + npc.start_working() + manager.simulate_tick() + + _check( + is_equal_approx(resource.get_amount_remaining(), 8.0) + and is_equal_approx( + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), + 2.0 + ), + "Gathering should move food from the source into NPC inventory" + ) + _check( + is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0), + "Gathering should not teleport food into storage" + ) + + manager.simulate_tick() + _check( + npc.current_task == SimulationIds.ACTION_DEPOSIT_FOOD, + "Carried food should select the deposit action" + ) + manager.notify_npc_arrived(npc.id) + manager.simulate_tick() + _check( + is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 2.0) + and is_equal_approx( + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), + 0.0 + ), + "Deposit should transfer carried food into the pantry" + ) + + npc.hunger = 80.0 + manager.simulate_tick() + _check( + npc.current_task == SimulationIds.ACTION_WITHDRAW_FOOD, + "Hungry NPC should retrieve food from storage" + ) + manager.notify_npc_arrived(npc.id) + manager.simulate_tick() + _check( + is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 1.0) + and is_equal_approx( + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), + 1.0 + ), + "Withdraw should transfer pantry food into NPC inventory" + ) + + manager.simulate_tick() + _check( + npc.current_task == SimulationIds.ACTION_EAT, + "NPC carrying food should select eat" + ) + manager.notify_npc_arrived(npc.id) + var hunger_before_eating := npc.hunger + manager.simulate_tick() + manager.simulate_tick() + _check( + npc.hunger < hunger_before_eating + and is_equal_approx( + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), + 0.0 + ), + "Eating should consume carried food and reduce hunger" + ) + + var conserved_food := ( + resource.get_amount_remaining() + + pantry.get_amount(SimulationIds.RESOURCE_FOOD) + + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) + ) + _check( + is_equal_approx(conserved_food, 9.0), + "Exactly one food unit should leave the world when eaten" + ) + + if failures.is_empty(): + print("[TEST] Food storage loop passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/food_storage_loop_test.gd.uid b/tests/food_storage_loop_test.gd.uid new file mode 100644 index 0000000..e788eb0 --- /dev/null +++ b/tests/food_storage_loop_test.gd.uid @@ -0,0 +1 @@ +uid://cpyi0mc2gjs7l diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd index f878b56..4a95f3c 100644 --- a/tests/simulation_definitions_test.gd +++ b/tests/simulation_definitions_test.gd @@ -35,8 +35,8 @@ func _test_registry_integrity() -> void: "Action lookup should return '%s'" % definition.action_id ) _check( - action_ids.size() == 7, - "Registry should contain all seven executable prototype actions" + action_ids.size() == 9, + "Registry should contain all nine executable prototype actions" ) var profession_ids := SimulationDefinitions.get_profession_ids() diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index ca59366..a4e9b5e 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -10,6 +10,7 @@ func _run() -> void: await _test_resource_state_round_trip() _test_legacy_npc_migration() _test_legacy_resource_migration() + _test_legacy_world_storage_migration() _test_schema_rejection() if failures.is_empty(): @@ -182,6 +183,26 @@ func _test_legacy_npc_migration() -> void: "Legacy NPC migration should not invent an active travel target" ) +func _test_legacy_world_storage_migration() -> void: + var manager := _create_manager(55) + var legacy_data: Dictionary = manager.create_state_record().to_dictionary() + legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION + legacy_data.erase("storages") + var migrated := SimulationStateRecord.from_dictionary(legacy_data) + _check(migrated != null, "World schema v1 should migrate pantry storage") + if migrated != null: + _check( + migrated.storages.size() == 1 + and is_equal_approx( + migrated.storages[0].get_amount( + SimulationIds.RESOURCE_FOOD + ), + manager.village.food + ), + "World migration should preserve legacy village food in pantry" + ) + 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/active_world_adapter.gd b/world/active_world_adapter.gd index 006e55a..fd859e6 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -4,7 +4,7 @@ extends Node @export var guard_marker: Marker3D @export var study_marker: Marker3D @export var rest_marker: Marker3D -@export var food_marker: Marker3D +@export var pantry_marker: Marker3D func get_resource_candidates(action_id: StringName) -> Array[Dictionary]: var candidates: Array[Dictionary] = [] @@ -27,7 +27,16 @@ func get_activity_target(action_id: StringName) -> Dictionary: SimulationIds.ACTION_REST: marker = rest_marker SimulationIds.ACTION_EAT: - marker = food_marker + marker = pantry_marker + SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD: + marker = pantry_marker if marker == null: return {} - return {"target_id": "", "position": marker.global_position} + var target_id := "" + if action_id in [ + SimulationIds.ACTION_EAT, + SimulationIds.ACTION_DEPOSIT_FOOD, + SimulationIds.ACTION_WITHDRAW_FOOD + ]: + target_id = String(SimulationIds.STORAGE_VILLAGE_PANTRY) + return {"target_id": target_id, "position": marker.global_position} diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 1db5e1b..7d97a80 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -177,7 +177,7 @@ yield_per_action = 3.0 [node name="LegacyActivityMarkers" type="Node3D" parent="."] -[node name="FoodZone" type="Marker3D" parent="LegacyActivityMarkers"] +[node name="PantryMarker" type="Marker3D" parent="LegacyActivityMarkers"] position = Vector3(0, 0, -8) [node name="GuardZone" type="Marker3D" parent="LegacyActivityMarkers"]