From 549a365ab28baea6cf48e993c6ea01469a8239d7 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Tue, 11 Aug 2026 00:40:13 +0200 Subject: [PATCH] feat: regrow finite berry sources toward their authored cap --- simulation/SimulationManager.gd | 7 ++ simulation/state/ResourceStateRecord.gd | 50 ++++++++- tests/action_system_boundaries_test.gd | 4 + tests/food_storage_loop_test.gd | 2 + tests/resource_regrowth_test.gd | 132 ++++++++++++++++++++++++ tests/resource_regrowth_test.gd.uid | 1 + world/jajce/JajceWorld.tscn | 3 + world/resource_nodes/ResourceNode.gd | 2 + 8 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/resource_regrowth_test.gd create mode 100644 tests/resource_regrowth_test.gd.uid diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 917daba..64f62cf 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -171,6 +171,7 @@ func simulate_tick() -> void: print("--- Tick ", tick_count, " ---") animal_care.advance(tick_count) advance_player_needs() + _advance_resource_regrowth() var village_was_changed := false _population_view.rebuild(npcs) for npc in npcs: @@ -1065,6 +1066,12 @@ func advance_player_needs() -> void: player_system.advance_needs(village.food_modifier) +func _advance_resource_regrowth() -> void: + for resource_id in resource_states: + var resource_state := resource_states[resource_id] as ResourceStateRecord + resource_state.regrow() + + func get_player_state() -> PlayerStateRecord: return player_system.player_state diff --git a/simulation/state/ResourceStateRecord.gd b/simulation/state/ResourceStateRecord.gd index e789c7d..49f4e06 100644 --- a/simulation/state/ResourceStateRecord.gd +++ b/simulation/state/ResourceStateRecord.gd @@ -4,9 +4,10 @@ extends RefCounted signal changed(state: ResourceStateRecord) signal depleted(state: ResourceStateRecord) -const SCHEMA_VERSION := 3 +const SCHEMA_VERSION := 4 const LEGACY_SCHEMA_VERSION := 1 const DISCOVERY_LEGACY_SCHEMA_VERSION := 2 +const REGROW_LEGACY_SCHEMA_VERSION := 3 var data: Dictionary @@ -24,6 +25,8 @@ static func create_from_node(node: ResourceNode) -> ResourceStateRecord: "resource_id": String(node.resource_id), "amount_remaining": node.initial_amount, "yield_per_action": node.yield_per_action, + "max_amount": node.max_amount if node.max_amount > 0.0 else node.initial_amount, + "regrow_rate": node.regrow_rate, "reserved_by": -1, "enabled": node.initial_enabled, "can_npcs_use": node.can_npcs_use, @@ -41,6 +44,8 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: record_data = _migrate_v1(record_data) elif version == DISCOVERY_LEGACY_SCHEMA_VERSION: record_data = _migrate_v2(record_data) + elif version == REGROW_LEGACY_SCHEMA_VERSION: + record_data = _migrate_v3(record_data) elif version != SCHEMA_VERSION: return null @@ -51,6 +56,8 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: "resource_id", "amount_remaining", "yield_per_action", + "max_amount", + "regrow_rate", "reserved_by", "enabled", "can_npcs_use", @@ -73,6 +80,8 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: normalized["resource_id"] = String(record_data["resource_id"]) normalized["amount_remaining"] = float(record_data["amount_remaining"]) normalized["yield_per_action"] = float(record_data["yield_per_action"]) + normalized["max_amount"] = float(record_data["max_amount"]) + normalized["regrow_rate"] = float(record_data["regrow_rate"]) normalized["reserved_by"] = int(record_data["reserved_by"]) normalized["enabled"] = bool(record_data["enabled"]) normalized["can_npcs_use"] = bool(record_data["can_npcs_use"]) @@ -80,6 +89,13 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: normalized["safety_risk"] = float(record_data["safety_risk"]) normalized["comfort_distance"] = float(record_data["comfort_distance"]) normalized["discovery_priority"] = float(record_data["discovery_priority"]) + if ( + not is_finite(normalized["max_amount"]) + or normalized["max_amount"] < 0.0 + or not is_finite(normalized["regrow_rate"]) + or normalized["regrow_rate"] < 0.0 + ): + return null return ResourceStateRecord.new(normalized) @@ -102,6 +118,15 @@ static func _migrate_v2(legacy_data: Dictionary) -> Dictionary: migrated["safety_risk"] = clampf(float(migrated.get("safety_risk", 0.0)), 0.0, 1.0) migrated["comfort_distance"] = maxf(float(migrated.get("comfort_distance", 18.0)), 0.1) migrated["discovery_priority"] = float(migrated.get("discovery_priority", 0.0)) + return _migrate_v3(migrated) + + +static func _migrate_v3(legacy_data: Dictionary) -> Dictionary: + var migrated := legacy_data.duplicate(true) + migrated["schema_version"] = SCHEMA_VERSION + var remaining := float(migrated.get("amount_remaining", 0.0)) + migrated["max_amount"] = maxf(float(migrated.get("max_amount", remaining)), 0.0) + migrated["regrow_rate"] = maxf(float(migrated.get("regrow_rate", 0.0)), 0.0) return migrated @@ -114,6 +139,8 @@ func apply_definition(node: ResourceNode) -> bool: data["action_id"] = String(node.action_id) data["resource_id"] = String(node.resource_id) data["yield_per_action"] = node.yield_per_action + data["max_amount"] = node.max_amount if node.max_amount > 0.0 else node.initial_amount + data["regrow_rate"] = node.regrow_rate data["can_npcs_use"] = node.can_npcs_use data["can_player_use"] = node.can_player_use data["safety_risk"] = node.safety_risk @@ -151,6 +178,27 @@ func get_yield_per_action() -> float: return float(data["yield_per_action"]) +func get_max_amount() -> float: + return maxf(float(data["max_amount"]), 0.0) + + +func get_regrow_rate() -> float: + return maxf(float(data["regrow_rate"]), 0.0) + + +func regrow() -> float: + var rate := get_regrow_rate() + if rate <= 0.0 or is_equal_approx(get_amount_remaining(), get_max_amount()): + return 0.0 + var previous := get_amount_remaining() + var next := minf(previous + rate, get_max_amount()) + data["amount_remaining"] = next + if is_depleted(): + data["reserved_by"] = -1 + changed.emit(self) + return next - previous + + func get_reserved_by() -> int: return int(data["reserved_by"]) diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index 9c6cdb8..bc3faa6 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -166,6 +166,8 @@ func _test_target_resolution_and_travel_are_separate() -> void: "resource_id": String(SimulationIds.RESOURCE_FOOD), "amount_remaining": 5.0, "yield_per_action": 1.0, + "max_amount": 5.0, + "regrow_rate": 0.0, "reserved_by": -1, "enabled": true, "can_npcs_use": true, @@ -184,6 +186,8 @@ func _test_target_resolution_and_travel_are_separate() -> void: "resource_id": String(SimulationIds.RESOURCE_FOOD), "amount_remaining": 5.0, "yield_per_action": 1.0, + "max_amount": 5.0, + "regrow_rate": 0.0, "reserved_by": -1, "enabled": true, "can_npcs_use": true, diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index 91ef0ec..1471c0e 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -25,6 +25,8 @@ func _run() -> void: "resource_id": String(SimulationIds.RESOURCE_FOOD), "amount_remaining": 10.0, "yield_per_action": 2.0, + "max_amount": 10.0, + "regrow_rate": 0.0, "reserved_by": -1, "enabled": true, "can_npcs_use": true, diff --git a/tests/resource_regrowth_test.gd b/tests/resource_regrowth_test.gd new file mode 100644 index 0000000..890719d --- /dev/null +++ b/tests/resource_regrowth_test.gd @@ -0,0 +1,132 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var manager := _create_manager(961) + var bush := ResourceNode.new() + bush.name = "RegrowBush" + bush.node_id = &"regrow_bush" + bush.action_id = SimulationIds.ACTION_GATHER_FOOD + bush.resource_id = SimulationIds.RESOURCE_FOOD + bush.initial_amount = 2.0 + bush.max_amount = 5.0 + bush.regrow_rate = 0.1 + bush.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + interaction_point.position = Vector3.ZERO + bush.add_child(interaction_point) + root.add_child(bush) + await process_frame + _check( + manager.register_resource_node(bush), "Regrowth test should bind a real finite ResourceNode" + ) + var state: ResourceStateRecord = manager.get_resource_state(bush.node_id) + _check( + ( + is_equal_approx(state.get_amount_remaining(), 2.0) + and is_equal_approx(state.get_max_amount(), 5.0) + and is_equal_approx(state.get_regrow_rate(), 0.1) + ), + "Regrowth metadata should bind from the authored node" + ) + + _check( + is_equal_approx(manager.harvest_resource_node(bush), 2.0), + "Harvesting should deplete the regrowing bush into carried inventory" + ) + _check( + is_equal_approx(state.get_amount_remaining(), 0.0), + "The regrowing bush should reach zero after harvesting" + ) + manager.simulate_tick() + manager.simulate_tick() + _check( + is_equal_approx(state.get_amount_remaining(), 0.2), + "Regrowing resources should recover exactly their rate per simulation tick" + ) + for _tick in 50: + manager.simulate_tick() + _check( + ( + is_equal_approx(state.get_amount_remaining(), 5.0) + and state.get_amount_remaining() <= state.get_max_amount() + ), + "Regrowth should stop at the authored maximum without creating extra food" + ) + + manager.simulate_tick() + var saved_json: String = manager.serialize_state() + var restored := _create_manager(962) + _check( + restored.restore_state_from_json(saved_json), + "Regrowing resource state should survive save and restore" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and is_equal_approx( + restored.get_resource_state(bush.node_id).get_amount_remaining(), 5.0 + ) + and is_equal_approx(restored.get_resource_state(bush.node_id).get_regrow_rate(), 0.1) + ), + "Restore should preserve the regrow amount, cap, and checksum" + ) + restored.free() + + var v3_data: Dictionary = state.to_dictionary() + v3_data["schema_version"] = ResourceStateRecord.REGROW_LEGACY_SCHEMA_VERSION + v3_data.erase("max_amount") + v3_data.erase("regrow_rate") + var migrated := ResourceStateRecord.from_dictionary(v3_data) + _check( + ( + migrated != null + and is_equal_approx(migrated.get_max_amount(), 5.0) + and is_equal_approx(migrated.get_regrow_rate(), 0.0) + ), + "Resource v3 should migrate with its current amount as cap and no regrowth" + ) + + bush.queue_free() + manager.free() + _finish() + + +func _create_manager(seed_value: int = 961) -> Node: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.simulation_seed = seed_value + manager.debug_logs = false + var home_positions: Array[Vector3] = [ + Vector3(0.0, 0.0, 0.0), + Vector3(2.0, 0.0, 0.0), + Vector3(10.0, 0.0, 0.0), + Vector3(12.0, 0.0, 0.0), + Vector3(30.0, 0.0, 30.0), + Vector3(40.0, 0.0, 40.0), + ] + manager.home_positions = home_positions + root.add_child(manager) + manager.set_process(false) + return manager + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Resource regrowth passed: bounded recovery -> cap -> restore") + 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/resource_regrowth_test.gd.uid b/tests/resource_regrowth_test.gd.uid new file mode 100644 index 0000000..d5c90fd --- /dev/null +++ b/tests/resource_regrowth_test.gd.uid @@ -0,0 +1 @@ +uid://bxxet5dkxrk1p diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index fa36e8c..a7c4c38 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -490,6 +490,7 @@ position = Vector3(-8, 0, -17) [node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] position = Vector3(-6, 0, -8) node_id = &"berry_bush_01" +regrow_rate = 0.02 safety_risk = 0.05 comfort_distance = 18.0 discovery_priority = 1.0 @@ -498,6 +499,7 @@ discovery_priority = 1.0 position = Vector3(-10, 0, -4) node_id = &"berry_bush_02" initial_amount = 8.0 +regrow_rate = 0.02 safety_risk = 0.08 comfort_distance = 20.0 discovery_priority = 0.5 @@ -507,6 +509,7 @@ position = Vector3(12, 0, 10) node_id = &"berry_patch_river_01" initial_amount = 6.0 yield_per_action = 1.5 +regrow_rate = 0.02 safety_risk = 0.18 comfort_distance = 22.0 discovery_priority = 0.25 diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 9359649..22f2848 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -12,6 +12,8 @@ static var _all: Array[ResourceNode] = [] @export var resource_id: StringName = SimulationIds.RESOURCE_FOOD @export var initial_amount: float = 10.0 @export var yield_per_action: float = 2.0 +@export var max_amount: float = 0.0 +@export var regrow_rate: float = 0.0 @export var initial_enabled: bool = true @export var can_npcs_use: bool = true @export var can_player_use: bool = true