diff --git a/main.tscn b/main.tscn index 1fa8c69..4fc27ba 100644 --- a/main.tscn +++ b/main.tscn @@ -25,13 +25,14 @@ height = 1.7 [node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")] -[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "world_view_manager", "pantry_storage", "guard_site", "study_site")] +[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "world_view_manager", "pantry_storage", "woodpile_storage", "guard_site", "study_site")] 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") world_view_manager = NodePath("../WorldViewManager") pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry") +woodpile_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillageWoodpile") guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost") study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk") stomach_capacity_for_food = 10 diff --git a/player/PlayerInteractionResult.gd b/player/PlayerInteractionResult.gd index ee3f3a2..c326cc4 100644 --- a/player/PlayerInteractionResult.gd +++ b/player/PlayerInteractionResult.gd @@ -4,6 +4,7 @@ extends RefCounted const KIND_ANIMAL := &"animal" const KIND_RESOURCE := &"resource" const KIND_PANTRY := &"pantry" +const KIND_DEPOSIT := &"deposit" const KIND_GUARD := &"guard" const KIND_STUDY := &"study" diff --git a/player/player.gd b/player/player.gd index 67c0611..b2a31e0 100644 --- a/player/player.gd +++ b/player/player.gd @@ -11,6 +11,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool) @export var world_view_manager: Node @export var pantry_storage: StorageNode +@export var woodpile_storage: StorageNode @export var guard_site: ActivitySite @export var study_site: ActivitySite @@ -69,6 +70,8 @@ func try_interact() -> void: _execute_animal_interaction(context) PlayerInteractionResult.KIND_RESOURCE: _execute_resource_interaction(context) + PlayerInteractionResult.KIND_DEPOSIT: + _execute_deposit_interaction(context) PlayerInteractionResult.KIND_GUARD: simulation_manager.add_safety(3.0) interaction_feedback.emit( @@ -112,6 +115,20 @@ func get_interaction_context() -> PlayerInteractionResult: "Work here · +2 knowledge", study_site ) + if is_near_storage(woodpile_storage) and _get_player_carried(SimulationIds.RESOURCE_WOOD) > 0.0: + return _build_deposit_context( + SimulationIds.RESOURCE_WOOD, + SimulationIds.STORAGE_VILLAGE_WOODPILE, + woodpile_storage, + "Village woodpile" + ) + if is_near_storage(pantry_storage) and _get_player_carried(SimulationIds.RESOURCE_FOOD) > 0.0: + return _build_deposit_context( + SimulationIds.RESOURCE_FOOD, + SimulationIds.STORAGE_VILLAGE_PANTRY, + pantry_storage, + "Village pantry" + ) if is_near_storage(pantry_storage): return _build_pantry_context() return null @@ -290,14 +307,40 @@ func _build_resource_context(node: ResourceNode) -> PlayerInteractionResult: definition.display_name if definition != null else String(node.action_id).capitalize() ) var display_name := String(node.node_id).replace("_", " ").capitalize() + var carried := _get_player_carried(node.resource_id) + var detail := ( + "%s · %.1f remaining · carrying %.1f" % [display_name, node.get_amount_remaining(), carried] + ) + if _get_available_carry() <= 0.0: + detail = "Hands full — deposit before gathering" return PlayerInteractionResult.new( PlayerInteractionResult.KIND_RESOURCE, node.action_id, node.node_id, display_name, prompt, - "%s · %.1f remaining" % [display_name, node.get_amount_remaining()], - node + detail, + node, + "" if _get_available_carry() > 0.0 else "You cannot carry more right now." + ) + + +func _build_deposit_context( + resource_id: StringName, target_id: StringName, target_node: StorageNode, display_name: String +) -> PlayerInteractionResult: + var carried := _get_player_carried(resource_id) + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_DEPOSIT, + ( + SimulationIds.ACTION_DEPOSIT_FOOD + if resource_id == SimulationIds.RESOURCE_FOOD + else SimulationIds.ACTION_DEPOSIT_WOOD + ), + target_id, + display_name, + "Deposit %s" % String(resource_id).capitalize(), + "%.1f %s in your hands" % [carried, String(resource_id).to_lower()], + target_node ) @@ -349,23 +392,51 @@ func _execute_animal_interaction(context: PlayerInteractionResult) -> void: func _execute_resource_interaction(context: PlayerInteractionResult) -> void: var node := context.target_node as ResourceNode - if node == null or not simulation_manager.has_method("harvest_resource_node"): - push_error("Player: SimulationManager cannot harvest the resolved ResourceNode") + if node == null or not simulation_manager.has_method("player_gather"): + push_error("Player: SimulationManager cannot gather the resolved ResourceNode") return - var extracted: float = simulation_manager.harvest_resource_node(node) + var extracted: float = simulation_manager.player_gather(node) if extracted <= 0.0: interaction_feedback.emit( - "%s is unavailable" % context.display_name, "Nothing could be gathered.", false + "%s is unavailable" % context.display_name, + ( + context.blocked_reason + if not context.blocked_reason.is_empty() + else "Nothing could be gathered." + ), + false ) return - var destination := "pantry" if node.resource_id == SimulationIds.RESOURCE_FOOD else "woodpile" + var carried := _get_player_carried(node.resource_id) interaction_feedback.emit( "%s gathered" % String(node.resource_id).capitalize(), - "%.1f moved to the village %s." % [extracted, destination], + "%.1f added to your hands · carrying %.1f" % [extracted, carried], true ) +func _deposit_carried(resource_id: StringName) -> float: + if not simulation_manager.has_method("player_deposit"): + push_error("Player: SimulationManager cannot deposit carried resources") + return 0.0 + return simulation_manager.player_deposit(resource_id) + + +func _execute_deposit_interaction(context: PlayerInteractionResult) -> void: + var resource_id := ( + SimulationIds.RESOURCE_FOOD + if context.action_id == SimulationIds.ACTION_DEPOSIT_FOOD + else SimulationIds.RESOURCE_WOOD + ) + var deposited: float = _deposit_carried(resource_id) + if deposited <= 0.0: + interaction_feedback.emit("Deposit blocked", "You are not carrying that here.", false) + return + interaction_feedback.emit( + "Deposited to %s" % context.display_name, "%.1f moved into the village." % deposited, true + ) + + func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) @@ -424,6 +495,20 @@ func _get_pantry_food() -> float: return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0 +func _get_player_carried(resource_id: StringName) -> float: + if simulation_manager == null or not simulation_manager.has_method("get_player_state"): + return 0.0 + var state: PlayerStateRecord = simulation_manager.get_player_state() + return state.get_inventory_amount(resource_id) if state != null else 0.0 + + +func _get_available_carry() -> float: + if simulation_manager == null or not simulation_manager.has_method("get_player_state"): + return 0.0 + var state: PlayerStateRecord = simulation_manager.get_player_state() + return state.get_available_carry() if state != null else 0.0 + + func is_near_storage(storage_node: StorageNode) -> bool: if storage_node == null: return false diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index fba2d12..aed6b4d 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -30,6 +30,7 @@ signal player_quest_expired(quest: PlayerQuestRecord) signal player_standing_changed(standing: PlayerStandingRecord) signal player_tier_advanced(tier: int, tier_name: String) signal player_needs_changed(state: PlayerStateRecord) +signal player_inventory_changed(state: PlayerStateRecord) signal player_died(state: PlayerStateRecord) var village := SimVillage.new() @@ -85,6 +86,8 @@ func _ready() -> void: event_log.event_recorded.connect(_on_economic_event_recorded) event_recorder.configure(event_log, Callable(self, "get_tick_count_for_recording")) event_recorder.set_npcs(npcs) + player_needs.configure(economy, event_recorder) + player_needs.set_resource_states(resource_states) economy.inventory_changed.connect(_on_economy_inventory_changed) economy.economic_event_requested.connect(event_recorder.record_economic) economy.narrative_event_requested.connect(event_recorder.record_narrative) @@ -578,30 +581,28 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void: for learned_record in learned_records: _apply_new_event_knowledge(learned_record, event) if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED: - _resolve_animal_care_quest(event) + _finish_quest_resolution( + player_quest_system.on_animal_fed( + animal_care.get_state(StringName(event.data["destination_id"])), event, tick_count + ) + ) _update_opportunity_from_event(event) economic_event_recorded.emit(event) -func _resolve_animal_care_quest(event: EconomicEventRecord) -> void: - var animal_state := animal_care.get_state(StringName(event.data["destination_id"])) - var resolution := player_quest_system.on_animal_fed(animal_state, event, tick_count) - _finish_quest_resolution(resolution) - - func _finish_quest_resolution(resolution: Dictionary) -> void: if not resolution.has("quest"): return var quest: PlayerQuestRecord = resolution["quest"] - if resolution["completed"]: - player_quest_completed.emit(quest) - var standing := player_quest_system.standing - player_standing_changed.emit(standing) - if standing.get_tier() > _last_player_tier: - _last_player_tier = standing.get_tier() - player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name()) - else: + if not resolution["completed"]: player_quest_expired.emit(quest) + return + player_quest_completed.emit(quest) + var standing := player_quest_system.standing + player_standing_changed.emit(standing) + if standing.get_tier() > _last_player_tier: + _last_player_tier = standing.get_tier() + player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name()) func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bool: @@ -609,12 +610,11 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo var listener := _find_npc_by_id(listener_id) if not _can_communicate_at_shared_activity(speaker, listener): return false - var known_event_ids := event_knowledge_system.get_communicable_event_ids( + for event_id in event_knowledge_system.get_communicable_event_ids( speaker.id, _get_lasting_event_ids(speaker.id), opportunity_system.get_open_trigger_event_id() - ) - for event_id in known_event_ids: + ): var event := event_log.get_by_id(event_id) if event == null: continue @@ -742,22 +742,21 @@ func get_active_player_quest() -> PlayerQuestRecord: func _advance_player_needs() -> void: - if not player_needs.advance(village): - player_needs_changed.emit(player_needs.state) + if player_needs.advance(village): + player_died.emit(player_needs.state) + player_needs.apply_death_consequence(player_quest_system.standing) + _last_player_tier = player_quest_system.standing.get_tier() + player_standing_changed.emit(player_quest_system.standing) return - player_died.emit(player_needs.state) - player_needs.apply_death_consequence(player_quest_system.standing) - _last_player_tier = player_quest_system.standing.get_tier() - player_standing_changed.emit(player_quest_system.standing) + player_needs_changed.emit(player_needs.state) func respawn_player() -> bool: - var was_dead := player_needs.state.is_dead() + if not player_needs.state.is_dead(): + return false player_needs.respawn() - if was_dead and not player_needs.state.is_dead(): - player_needs_changed.emit(player_needs.state) - return true - return false + player_needs_changed.emit(player_needs.state) + return true func get_player_state() -> PlayerStateRecord: @@ -771,6 +770,21 @@ func player_eat(amount: float) -> float: return eaten +func player_gather(node: ResourceNode) -> float: + var gathered := player_needs.gather_node(node) + if gathered > 0.0: + player_inventory_changed.emit(player_needs.state) + return gathered + + +func player_deposit(resource_id: StringName) -> float: + var deposited := player_needs.deposit_resource(resource_id) + if deposited > 0.0: + village_changed.emit(village) + player_inventory_changed.emit(player_needs.state) + return deposited + + func _consider_animal_care_quests() -> void: var pantry: StorageStateRecord = get_pantry() for animal_state in animal_care.get_all_states(): diff --git a/simulation/opportunities/VillageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd index d12a5dd..20ba709 100644 --- a/simulation/opportunities/VillageOpportunitySystem.gd +++ b/simulation/opportunities/VillageOpportunitySystem.gd @@ -537,6 +537,8 @@ static func _is_valid_supply( var event_type := StringName(event.data["event_type"]) var actor_id := int(event.data["actor_id"]) if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + if actor_id < 0: + return StringName(event.data["source_id"]) == &"player_carry" return ( _find_npc(actor_id, npcs) != null and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) diff --git a/simulation/quests/PlayerNeedsSystem.gd b/simulation/quests/PlayerNeedsSystem.gd index 2dcf25c..c756635 100644 --- a/simulation/quests/PlayerNeedsSystem.gd +++ b/simulation/quests/PlayerNeedsSystem.gd @@ -2,6 +2,18 @@ class_name PlayerNeedsSystem extends RefCounted var state := PlayerStateRecord.create() +var economy: RefCounted +var event_recorder: RefCounted +var resource_states: Dictionary = {} + + +func configure(economy_service: RefCounted, recorder: RefCounted) -> void: + economy = economy_service + event_recorder = recorder + + +func set_resource_states(states: Dictionary) -> void: + resource_states = states func advance(village: SimVillage) -> bool: @@ -15,8 +27,80 @@ func advance(village: SimVillage) -> bool: func eat(amount: float) -> float: - var eaten := state.eat(amount) - return eaten + return state.eat(amount) + + +func gather_node(node: ResourceNode) -> float: + if node == null: + return 0.0 + var resource_state := resource_states.get(node.node_id) as ResourceStateRecord + if resource_state == null or not resource_state.can_player_use_resource(): + return 0.0 + var available := state.get_available_carry() + if available <= 0.0: + return 0.0 + var extracted := resource_state.extract(minf(resource_state.get_yield_per_action(), available)) + if extracted <= 0.0: + return 0.0 + var carried := state.add_inventory(resource_state.get_resource_id(), extracted) + if carried <= 0.0: + resource_state.data["amount_remaining"] = ( + resource_state.get_amount_remaining() + extracted + ) + return 0.0 + var event_position := ( + node.interaction_point.global_position + if node.interaction_point != null + else node.global_position + ) + event_recorder.record_economic_at( + SimulationIds.EVENT_RESOURCE_EXTRACTED, + -1, + resource_state.get_node_id(), + &"player_carry", + resource_state.get_resource_id(), + carried, + event_position + ) + if resource_state.get_amount_remaining() <= 0.0: + event_recorder.record_narrative_at( + SimulationIds.EVENT_RESOURCE_DEPLETED, + -1, + resource_state.get_node_id(), + "", + event_position + ) + return carried + + +func deposit_resource(resource_id: StringName) -> float: + if resource_id.is_empty(): + return 0.0 + var carried := state.get_inventory_amount(resource_id) + if carried <= 0.0: + return 0.0 + if economy == null or not economy.has_method("get_storage_for_resource"): + return 0.0 + var storage: StorageStateRecord = economy.get_storage_for_resource(resource_id) + if storage == null: + return 0.0 + var deposited: float = economy.deposit_resource(resource_id, carried) + if deposited <= 0.0: + return 0.0 + state.remove_inventory(resource_id, deposited) + event_recorder.record_economic( + SimulationIds.EVENT_STORAGE_DEPOSITED, + -1, + &"player_carry", + storage.get_storage_id(), + resource_id, + deposited + ) + return deposited + + +func get_carried_amount(item_id: StringName) -> float: + return state.get_inventory_amount(item_id) func respawn() -> void: diff --git a/simulation/state/PlayerStateRecord.gd b/simulation/state/PlayerStateRecord.gd index 02cf6b5..a8c9a15 100644 --- a/simulation/state/PlayerStateRecord.gd +++ b/simulation/state/PlayerStateRecord.gd @@ -7,6 +7,7 @@ const STACK_THRESHOLD := 90.0 const DEATH_STANDING_PENALTY := 0.5 const RESET_HUNGER := 20.0 const RESET_ENERGY := 70.0 +const DEFAULT_CARRY_CAPACITY := 4.0 var data: Dictionary @@ -28,6 +29,8 @@ static func create() -> PlayerStateRecord: "starvation_death_threshold": 600, "is_dead": false, "deaths": 0, + "inventory": {}, + "carry_capacity": DEFAULT_CARRY_CAPACITY, } ) ) @@ -68,6 +71,28 @@ static func from_dictionary(record_data: Dictionary) -> PlayerStateRecord: or deaths < 0 ): return null + var inventory := {} + var carry_capacity := DEFAULT_CARRY_CAPACITY + if record_data.has("inventory"): + if not record_data["inventory"] is Dictionary: + return null + var normalized := {} + for raw_item_id in record_data["inventory"]: + var item_id := String(raw_item_id) + var amount := float(record_data["inventory"][raw_item_id]) + if ( + item_id.is_empty() + or normalized.has(item_id) + or not is_finite(amount) + or amount < 0.0 + ): + return null + normalized[item_id] = amount + inventory = normalized + if record_data.has("carry_capacity"): + carry_capacity = float(record_data["carry_capacity"]) + if not is_finite(carry_capacity) or carry_capacity < 0.0: + return null return ( PlayerStateRecord . new( @@ -80,6 +105,8 @@ static func from_dictionary(record_data: Dictionary) -> PlayerStateRecord: "starvation_death_threshold": death_threshold, "is_dead": bool(record_data["is_dead"]), "deaths": deaths, + "inventory": inventory, + "carry_capacity": carry_capacity, } ) ) @@ -109,6 +136,47 @@ func get_deaths() -> int: return int(data["deaths"]) +func get_inventory_amount(item_id: StringName) -> float: + return float(data["inventory"].get(String(item_id), 0.0)) + + +func get_carry_capacity() -> float: + return float(data["carry_capacity"]) + + +func get_carried_total() -> float: + var total := 0.0 + for amount in data["inventory"].values(): + total += float(amount) + return total + + +func get_available_carry() -> float: + return maxf(get_carry_capacity() - get_carried_total(), 0.0) + + +func add_inventory(item_id: StringName, amount: float) -> float: + if item_id.is_empty() or not is_finite(amount) or amount <= 0.0 or is_dead(): + return 0.0 + var accepted := minf(amount, get_available_carry()) + if accepted <= 0.0: + return 0.0 + data["inventory"][String(item_id)] = get_inventory_amount(item_id) + accepted + return accepted + + +func remove_inventory(item_id: StringName, requested_amount: float) -> float: + if item_id.is_empty() or not is_finite(requested_amount) or requested_amount <= 0.0: + return 0.0 + var removed := minf(requested_amount, get_inventory_amount(item_id)) + if removed <= 0.0: + return 0.0 + data["inventory"][String(item_id)] = get_inventory_amount(item_id) - removed + if is_equal_approx(get_inventory_amount(item_id), 0.0): + data["inventory"].erase(String(item_id)) + return removed + + func eat(amount: float) -> float: if amount <= 0.0 or is_dead(): return 0.0 diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index ef2b647..0f218ad 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -772,6 +772,8 @@ static func _is_valid_supply_resolution( ): return false if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + if actor_id < 0: + return StringName(event.data["source_id"]) == &"player_carry" return ( npc_ids.has(actor_id) and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 8c8f6a3..a428679 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -298,17 +298,28 @@ func _run() -> void: opportunity_bush_state.set_amount_remaining(1.0) player.global_position = opportunity_bush.interaction_point.global_position player.call("try_interact") + var carried_after_gather: float = simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ) + var pantry_position: Vector3 = ( + main_scene + . get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") + . get_interaction_position() + ) + player.global_position = pantry_position + player.call("try_interact") village_ui.selected_npc_index = contributor.id village_ui.call("_refresh_npc_inspector") _check( ( - player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED + is_equal_approx(carried_after_gather, 1.0) + and player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED and "Resolved village need" in inspector_label.text and "◆ Player restocked the pantry" in inspector_label.text and whisper_kicker.text == "NEED MET" and "The player restocked the pantry" in whisper_message.text ), - "Player gathering should resolve the restored need in debug detail and transient HUD" + "Player gathering and depositing should resolve the restored need in debug detail and transient HUD" ) var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile() diff --git a/tests/player_carry_deposit_test.gd b/tests/player_carry_deposit_test.gd new file mode 100644 index 0000000..235c7b6 --- /dev/null +++ b/tests/player_carry_deposit_test.gd @@ -0,0 +1,188 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var manager := _create_manager() + var actor: SimNPC = manager.npcs[0] + var interested: SimNPC = manager.npcs[2] + var berry := _register_resource( + manager, + &"carry_berry_bush", + SimulationIds.ACTION_GATHER_FOOD, + SimulationIds.RESOURCE_FOOD, + 3.0 + ) + var pantry: StorageStateRecord = manager.get_pantry() + _set_pantry_amount(manager, 1.0) + for npc in manager.npcs: + npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0) + actor.position = Vector3.ZERO + interested.position = Vector3(4.0, 0.0, 0.0) + actor.hunger = 60.0 + interested.hunger = 90.0 + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0) + var quest: PlayerQuestRecord = manager.get_active_player_quest() + _check(quest != null, "The carry loop needs an open player quest") + if quest == null: + berry.free() + manager.free() + _finish() + return + + var state: PlayerStateRecord = manager.get_player_state() + var carried: float = manager.player_gather(berry) + _check( + ( + is_equal_approx(carried, 2.0) + and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 2.0) + and is_equal_approx(manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), 0.0) + ), + "Gathering should fill the player's hands instead of the pantry directly", + ) + var capacity_limited: float = manager.player_gather(berry) + _check( + ( + is_equal_approx(capacity_limited, 1.0) + and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 3.0) + ), + "Remaining resource amount should cap the second gather", + ) + var hands_full: float = manager.player_gather(berry) + _check( + ( + is_equal_approx(hands_full, 0.0) + and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 3.0) + ), + "An empty resource should refuse further gathering", + ) + + var pantry_before: float = manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD) + var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD) + _check( + ( + is_equal_approx(deposited, 3.0) + and is_equal_approx( + manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), pantry_before + 3.0 + ) + and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0) + and manager.get_active_player_quest() == null + and ( + manager.get_latest_player_quest_for_requester(interested.id).get_status() + == PlayerQuestRecord.STATUS_COMPLETED + ) + ), + "Depositing carried food should resolve the quest through the real pantry transaction", + ) + + var saved_json: String = manager.serialize_state() + var restored := _create_manager(902) + _check( + restored.restore_state_from_json(saved_json), + "The emptied carry state should restore through the current schema" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and is_equal_approx( + restored.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0 + ) + ), + "Restore should preserve the player's empty carried inventory and checksum", + ) + + var carry_state_saved: String = manager.serialize_state() + manager.player_gather(berry) + var mid_carry: String = manager.serialize_state() + var mid_restored := _create_manager(903) + _check( + mid_restored.restore_state_from_json(mid_carry), + "A partially carried inventory should restore through the current schema" + ) + _check( + ( + mid_restored.get_state_checksum() == manager.get_state_checksum() + and is_equal_approx( + mid_restored.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), + manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD) + ) + ), + "Restore should preserve the player's carried inventory mid-transport", + ) + + berry.free() + manager.free() + restored.free() + mid_restored.free() + _finish() + + +func _register_resource( + manager: Node, + node_id: StringName, + action_id: StringName, + resource_id: StringName, + amount: float +) -> ResourceNode: + var node := ResourceNode.new() + node.name = "CarryResource" + node.node_id = node_id + node.action_id = action_id + node.resource_id = resource_id + node.initial_amount = amount + node.yield_per_action = 2.0 + node.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + root.add_child(node) + _check( + manager.register_resource_node(node), + "The carry loop should bind a real finite ResourceNode" + ) + return node + + +func _create_manager(seed_value: int = 901) -> 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 _set_pantry_amount(manager: Node, amount: float) -> void: + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, amount) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Player carry and deposit loop passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/player_carry_deposit_test.gd.uid b/tests/player_carry_deposit_test.gd.uid new file mode 100644 index 0000000..319a50e --- /dev/null +++ b/tests/player_carry_deposit_test.gd.uid @@ -0,0 +1 @@ +uid://bwyg38d3ieiex diff --git a/tests/resource_node_player_parity_test.gd b/tests/resource_node_player_parity_test.gd index 3136455..a201068 100644 --- a/tests/resource_node_player_parity_test.gd +++ b/tests/resource_node_player_parity_test.gd @@ -35,11 +35,17 @@ func _run() -> void: _check( is_equal_approx(bush_state.get_amount_remaining(), 0.0), - "Player should deplete the nearby bush" + "Player should deplete the nearby bush into their hands" + ) + var carried_after_gather: float = simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD ) _check( - is_equal_approx(simulation_manager.village.food, food_before + 1.0), - "Village should receive exactly the bush's remaining food" + ( + is_equal_approx(carried_after_gather, 1.0) + and is_equal_approx(simulation_manager.village.food, food_before) + ), + "Gathering should fill the player's hands without bypassing the village store", ) _check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation") var player_extraction: EconomicEventRecord @@ -65,24 +71,41 @@ func _run() -> void: "Player depletion should preserve the resource interaction position" ) - player.global_position = tree.interaction_point.global_position - var wood_before: float = simulation_manager.village.wood - var tree_before := tree_state.get_amount_remaining() - player.try_interact() - - _check( - is_equal_approx(tree_state.get_amount_remaining(), tree_before - tree.yield_per_action), - "Player should extract the tree's configured yield" - ) - _check( - is_equal_approx(simulation_manager.village.wood, wood_before + tree.yield_per_action), - "Village should receive exactly the extracted wood" - ) - var pantry := ( main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode ) var pantry_state: StorageStateRecord = simulation_manager.get_pantry() + player.global_position = pantry.get_interaction_position() + player.try_interact() + var food_after_deposit: float = simulation_manager.village.food + _check( + ( + is_equal_approx(food_after_deposit, food_before + 1.0) + and is_equal_approx( + simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ), + 0.0 + ) + ), + "Depositing at the typed pantry should deliver the carried food to the village", + ) + + player.global_position = tree.interaction_point.global_position + var wood_before: float = simulation_manager.village.wood + var tree_before := tree_state.get_amount_remaining() + player.try_interact() + var carried_wood: float = simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_WOOD + ) + _check( + ( + is_equal_approx(tree_state.get_amount_remaining(), tree_before - tree.yield_per_action) + and is_equal_approx(carried_wood, tree.yield_per_action) + ), + "Player should extract the tree's configured yield into their hands", + ) + pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) bush_state.set_amount_remaining(1.0) @@ -90,20 +113,40 @@ func _run() -> void: player.try_interact() _check( ( - is_equal_approx(bush_state.get_amount_remaining(), 0.5) - and is_equal_approx(pantry_state.get_available_capacity(), 0.0) + is_equal_approx(bush_state.get_amount_remaining(), 0.0) + and is_equal_approx( + simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ), + 1.0 + ) ), - "Player harvesting should leave overflow at its source when storage fills" + "Player gathering should still extract into their hands regardless of storage capacity", ) - pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 3.0) + pantry_state.withdraw( + SimulationIds.RESOURCE_FOOD, pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) + ) + pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 5.0) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) var pantry_food_before := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) player.global_position = pantry.get_interaction_position() player.try_interact() + var food_after_first_deposit := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) + var food_carried_after_first: float = ( + simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD) + ) _check( - pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before, - "Player should eat from the typed pantry StorageNode" + ( + food_after_first_deposit > pantry_food_before + and is_equal_approx(food_carried_after_first, 0.0) + ), + "Player should deposit carried food at the typed pantry", + ) + player.try_interact() + _check( + pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < food_after_first_deposit, + "Player should eat from the pantry once their hands are empty", ) var guard_site := ( main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite