diff --git a/main.tscn b/main.tscn index 1815e61..40bfa97 100644 --- a/main.tscn +++ b/main.tscn @@ -15,6 +15,7 @@ [ext_resource type="Script" uid="uid://dwwxux24jc6yq" path="res://world/ui/village_whisper_hud.gd" id="15_whisper"] [ext_resource type="Script" path="res://world/ui/player_interaction_hud.gd" id="16_interaction"] [ext_resource type="Script" path="res://world/ui/villager_field_note_hud.gd" id="17_villager_note"] +[ext_resource type="Script" path="res://world/ui/player_status_hud.gd" id="18_status"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] radius = 0.4 @@ -24,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 @@ -195,6 +197,40 @@ theme_override_font_sizes/font_size = 13 text = "Context" horizontal_alignment = 1 +[node name="PlayerStatusLayer" type="CanvasLayer" parent="."] +layer = 2 + +[node name="PlayerStatusHud" type="Control" parent="PlayerStatusLayer" node_paths=PackedStringArray("simulation_manager")] +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -240.0 +offset_top = -170.0 +offset_right = 240.0 +offset_bottom = -134.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +script = ExtResource("18_status") +simulation_manager = NodePath("../../SimulationManager") + +[node name="StatusLabel" type="Label" parent="PlayerStatusLayer/PlayerStatusHud"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.95, 0.88, 0.74, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.92) +theme_override_constants/outline_size = 5 +theme_override_font_sizes/font_size = 14 +text = "Hunger 40 · Energy 100 · Carrying nothing" +horizontal_alignment = 1 + [node name="VillagerInspectionLayer" type="CanvasLayer" parent="."] layer = 2 diff --git a/player/PlayerInteractionResult.gd b/player/PlayerInteractionResult.gd index ee3f3a2..c48ca95 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_STORAGE_DEPOSIT := &"storage_deposit" const KIND_GUARD := &"guard" const KIND_STUDY := &"study" diff --git a/player/player.gd b/player/player.gd index 5a361b2..627b4bd 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 @@ -81,6 +82,8 @@ func try_interact() -> void: ) PlayerInteractionResult.KIND_PANTRY: _execute_pantry_interaction(context) + PlayerInteractionResult.KIND_STORAGE_DEPOSIT: + _execute_deposit_interaction(context) func get_interaction_context() -> PlayerInteractionResult: @@ -113,7 +116,12 @@ func get_interaction_context() -> PlayerInteractionResult: study_site ) if is_near_storage(pantry_storage): + if _get_carried_food() > 0.0: + return _build_deposit_context(SimulationIds.RESOURCE_FOOD, pantry_storage) return _build_pantry_context() + if woodpile_storage != null and is_near_storage(woodpile_storage): + if _get_carried_wood() > 0.0: + return _build_deposit_context(SimulationIds.RESOURCE_WOOD, woodpile_storage) return null @@ -346,10 +354,15 @@ func _build_resource_context(node: ResourceNode) -> PlayerInteractionResult: func _build_pantry_context() -> PlayerInteractionResult: var pantry_food := _get_pantry_food() - var blocked_reason := "" if pantry_food > 0.0 else "The village pantry is empty." + var carried_food := _get_carried_food() + var blocked_reason := "" var detail := "%.0f food available" % pantry_food - if not blocked_reason.is_empty(): - detail = "Pantry empty" + if pantry_food <= 0.0 and carried_food < 1.0: + blocked_reason = "The village pantry is empty and you carry no food." + detail = "Pantry empty · carry nothing" + elif pantry_food <= 0.0: + blocked_reason = "The village pantry is empty." + detail = "Pantry empty · carry %.0f food" % carried_food return PlayerInteractionResult.new( PlayerInteractionResult.KIND_PANTRY, SimulationIds.ACTION_EAT, @@ -362,6 +375,29 @@ func _build_pantry_context() -> PlayerInteractionResult: ) +func _build_deposit_context( + resource_id: StringName, storage_node: StorageNode +) -> PlayerInteractionResult: + var resource_name := String(resource_id).capitalize() + var carried := ( + _get_carried_food() if resource_id == SimulationIds.RESOURCE_FOOD else _get_carried_wood() + ) + var action_id := ( + SimulationIds.ACTION_DEPOSIT_FOOD + if resource_id == SimulationIds.RESOURCE_FOOD + else SimulationIds.ACTION_DEPOSIT_WOOD + ) + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_STORAGE_DEPOSIT, + action_id, + storage_node.storage_id, + storage_node.display_name, + "Deposit %s" % resource_name.to_lower(), + "%s · carrying %.0f" % [storage_node.display_name, carried], + storage_node + ) + + func _execute_animal_interaction(context: PlayerInteractionResult) -> void: var node := context.target_node as AnimalNode if node == null or not context.is_available(): @@ -401,10 +437,12 @@ func _execute_resource_interaction(context: PlayerInteractionResult) -> void: "%s is unavailable" % context.display_name, "Nothing could be gathered.", false ) return - var destination := "pantry" if node.resource_id == SimulationIds.RESOURCE_FOOD else "woodpile" interaction_feedback.emit( "%s gathered" % String(node.resource_id).capitalize(), - "%.1f moved to the village %s." % [extracted, destination], + ( + "%.1f added to your pack — deposit it at the %s." + % [extracted, _storage_name_for(node.resource_id)] + ), true ) @@ -413,12 +451,42 @@ func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) return - var food_before := _get_pantry_food() - simulation_manager.eat_food(stomach_capacity_for_food) - var consumed := food_before - _get_pantry_food() - interaction_feedback.emit( - "Ate from the pantry", "%.0f village food consumed." % consumed, consumed > 0.0 + if not simulation_manager.has_method("eat_player_food"): + push_error("Player: SimulationManager cannot consume player food") + return + if simulation_manager.eat_player_food(): + interaction_feedback.emit("Ate", "Hunger eased from carried or pantry food.", true) + return + interaction_feedback.emit("Nothing to eat", "The village pantry is empty.", false) + + +func _execute_deposit_interaction(context: PlayerInteractionResult) -> void: + if not simulation_manager.has_method("deposit_player_inventory"): + push_error("Player: SimulationManager cannot deposit player inventory") + return + var resource_id := ( + SimulationIds.RESOURCE_FOOD + if context.action_id == SimulationIds.ACTION_DEPOSIT_FOOD + else SimulationIds.RESOURCE_WOOD ) + var deposited: float = simulation_manager.deposit_player_inventory(resource_id) + var resource_name := String(resource_id).capitalize() + if deposited <= 0.0: + interaction_feedback.emit( + "%s not stored" % resource_name, + "The %s is full." % context.display_name.to_lower(), + false + ) + return + interaction_feedback.emit( + "%s stored" % resource_name, + "%.0f moved to the %s." % [deposited, context.display_name.to_lower()], + true + ) + + +func _storage_name_for(resource_id: StringName) -> String: + return "village pantry" if resource_id == SimulationIds.RESOURCE_FOOD else "village woodpile" func _get_animal_feed_cost() -> float: @@ -433,6 +501,18 @@ func _get_pantry_food() -> float: return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0 +func _get_carried_food() -> float: + if not simulation_manager.has_method("get_player_state"): + return 0.0 + return simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD) + + +func _get_carried_wood() -> float: + if not simulation_manager.has_method("get_player_state"): + return 0.0 + return simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_WOOD) + + 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 49420be..18fbd08 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -1,5 +1,9 @@ extends Node +# gdlint: disable=max-file-lines +# The manager is the deliberate scene-tree facade for the simulation slice and +# intentionally keeps its orchestration readable in one authoritative file. + const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.gd") @@ -25,6 +29,7 @@ signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: Ec signal opportunity_invalidated(opportunity: OpportunityStateRecord) var village := SimVillage.new() +var player_system := PlayerCitizenSystem.new() var npcs: Array[SimNPC] = [] @export var tick_interval := 1.2 @@ -89,6 +94,7 @@ func _ready() -> void: village.debug_logs = debug_logs economy.configure(village, debug_logs) animal_care.configure(economy, active_world_adapter) + player_system.configure(economy, _record_player_event_at) economy.initialize_storage() village.update_modifiers() village.update_priorities() @@ -164,6 +170,7 @@ func simulate_tick() -> void: if debug_logs: print("--- Tick ", tick_count, " ---") animal_care.advance(tick_count) + advance_player_needs() var village_was_changed := false _population_view.rebuild(npcs) for npc in npcs: @@ -984,9 +991,6 @@ func find_resource_node_for_player(from_position: Vector3, max_distance: float) or not resource_state.can_extract() ): continue - var storage := economy.get_storage_for_resource(resource_state.get_resource_id()) - if storage == null or storage.get_available_capacity() <= 0.0: - continue var distance := from_position.distance_squared_to(node.interaction_point.global_position) if distance <= best_distance: best_distance = distance @@ -1000,16 +1004,12 @@ func harvest_resource_node(node: ResourceNode) -> float: var resource_state := get_resource_state(node.node_id) if resource_state == null or not resource_state.can_player_use_resource(): return 0.0 - var storage := economy.get_storage_for_resource(resource_state.get_resource_id()) - if storage == null: - return 0.0 - var extracted := resource_state.extract( - minf(resource_state.get_yield_per_action(), storage.get_available_capacity()) - ) + var extracted := resource_state.extract(resource_state.get_yield_per_action()) if extracted <= 0.0: return 0.0 + var resource_id := resource_state.get_resource_id() + player_system.add_carried(resource_id, extracted) - var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted) var event_position := ( node.interaction_point.global_position if node.interaction_point != null @@ -1017,25 +1017,17 @@ func harvest_resource_node(node: ResourceNode) -> float: ) _record_economic_event_at( SimulationIds.EVENT_RESOURCE_EXTRACTED, - -1, + SimulationIds.PLAYER_ACTOR_ID, resource_state.get_node_id(), - ( - SimulationIds.STORAGE_VILLAGE_PANTRY - if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD - else ( - SimulationIds.STORAGE_VILLAGE_WOODPILE - if resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD - else &"village" - ) - ), - resource_state.get_resource_id(), - deposited, + SimulationIds.PLAYER_INVENTORY_ID, + resource_id, + extracted, event_position ) if resource_state.get_amount_remaining() <= 0.0: _record_narrative_event_at( SimulationIds.EVENT_RESOURCE_DEPLETED, - -1, + SimulationIds.PLAYER_ACTOR_ID, resource_state.get_node_id(), "", event_position @@ -1044,20 +1036,60 @@ func harvest_resource_node(node: ResourceNode) -> float: if debug_logs: print( - "[SimulationManager] Player extracted ", + "[SimulationManager] Player carried ", extracted, " ", - resource_state.get_resource_id(), + resource_id, " from ", resource_state.get_node_id() ) + return extracted + + +func advance_player_needs() -> void: + player_system.advance_needs(village.food_modifier) + + +func get_player_state() -> PlayerStateRecord: + return player_system.player_state + + +func deposit_player_inventory(item_id: StringName) -> float: + var storage := economy.get_storage_for_resource(item_id) + var storage_node := ( + StorageNode.get_by_id(storage.get_storage_id()) as StorageNode if storage != null else null + ) + var deposited := player_system.deposit( + item_id, storage_node.get_interaction_position() if storage_node != null else Vector3.ZERO + ) + village_changed.emit(village) return deposited -func eat_food(amount: float) -> void: - economy.withdraw_resource(SimulationIds.RESOURCE_FOOD, amount) +func eat_player_food() -> bool: + var succeeded := player_system.eat(Vector3.ZERO) village_changed.emit(village) + return succeeded + + +func _record_player_event_at( + event_type: StringName, + source_id: StringName, + destination_id: StringName, + item_id: StringName, + amount: float, + world_position: Vector3 +) -> void: + _record_economic_event_at( + event_type, + SimulationIds.PLAYER_ACTOR_ID, + source_id, + destination_id, + item_id, + amount, + world_position + ) func add_safety(amount: float) -> void: @@ -1125,6 +1157,7 @@ func create_state_record() -> SimulationStateRecord: record.event_knowledge.append(known_event) for opportunity in opportunity_system.get_all_sorted(): record.opportunities.append(opportunity) + record.player = player_system.player_state return record @@ -1175,6 +1208,8 @@ func restore_state(record: SimulationStateRecord) -> bool: resource_states[resource_record.get_node_id()] = resource_record register_loaded_resource_nodes() animal_care.restore_state_records(record.animals, tick_count) + if record.player != null: + player_system.player_state = record.player village_changed.emit(village) state_restored.emit() return true diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 006265a..5a2603d 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -2,6 +2,7 @@ class_name SimulationIds extends RefCounted const PLAYER_ACTOR_ID := -1 +const PLAYER_INVENTORY_ID := &"player_inventory" const ACTION_IDLE := &"idle" const ACTION_DEAD := &"dead" diff --git a/simulation/opportunities/VillageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd index d12a5dd..311e057 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 == SimulationIds.PLAYER_ACTOR_ID: + return StringName(event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID return ( _find_npc(actor_id, npcs) != null and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) diff --git a/simulation/player/player_citizen_system.gd b/simulation/player/player_citizen_system.gd new file mode 100644 index 0000000..2ee1787 --- /dev/null +++ b/simulation/player/player_citizen_system.gd @@ -0,0 +1,80 @@ +class_name PlayerCitizenSystem +extends RefCounted + +var player_state := PlayerStateRecord.create_default() +var economy: RefCounted +var _record_event: Callable + + +func configure(economy_state: RefCounted, record_event: Callable) -> void: + economy = economy_state + _record_event = record_event + + +func advance_needs(food_modifier: float) -> void: + player_state.set_hunger(player_state.get_hunger() + 0.125 * food_modifier) + player_state.set_energy(maxf(player_state.get_energy() - 0.0625, 0.0)) + + +func add_carried(item_id: StringName, amount: float) -> void: + player_state.add_inventory(item_id, amount) + + +func get_carried(item_id: StringName) -> float: + return player_state.get_inventory_amount(item_id) + + +func deposit(item_id: StringName, event_position: Vector3) -> float: + if item_id.is_empty(): + return 0.0 + var storage := _storage_for(item_id) + if storage == null: + return 0.0 + var deposited := storage.deposit(item_id, player_state.get_inventory_amount(item_id)) + player_state.remove_inventory(item_id, deposited) + economy.sync_resource(item_id) + if deposited > 0.0: + _record_event.call( + SimulationIds.EVENT_STORAGE_DEPOSITED, + SimulationIds.PLAYER_INVENTORY_ID, + storage.get_storage_id(), + item_id, + deposited, + event_position + ) + return deposited + + +func eat(event_position: Vector3) -> bool: + if player_state.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: + player_state.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + _apply_meal(event_position, SimulationIds.PLAYER_INVENTORY_ID) + return true + var pantry := _storage_for(SimulationIds.RESOURCE_FOOD) + if pantry != null and pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0: + var withdrawn := pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1.0) + economy.sync_resource(SimulationIds.RESOURCE_FOOD) + if withdrawn <= 0.0: + return false + _apply_meal(event_position, pantry.get_storage_id()) + return true + return false + + +func _apply_meal(event_position: Vector3, source_id: StringName) -> void: + player_state.set_hunger(maxf(player_state.get_hunger() - 55.0, 0.0)) + player_state.set_energy(minf(player_state.get_energy() + 10.0, 100.0)) + _record_event.call( + SimulationIds.EVENT_ITEM_CONSUMED, + source_id, + &"consumed", + SimulationIds.RESOURCE_FOOD, + 1.0, + event_position + ) + + +func _storage_for(item_id: StringName) -> StorageStateRecord: + if economy == null or not economy.has_method("get_storage_for_resource"): + return null + return economy.get_storage_for_resource(item_id) as StorageStateRecord diff --git a/simulation/player/player_citizen_system.gd.uid b/simulation/player/player_citizen_system.gd.uid new file mode 100644 index 0000000..02dbbcf --- /dev/null +++ b/simulation/player/player_citizen_system.gd.uid @@ -0,0 +1 @@ +uid://vqve206mikpt diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index f11f3d6..fdf710c 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,7 +2,7 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 12 +const SCHEMA_VERSION := 13 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 @@ -13,6 +13,7 @@ const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8 const ANIMAL_LEGACY_SCHEMA_VERSION := 9 const ROUTINE_LEGACY_SCHEMA_VERSION := 10 const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11 +const PLAYER_NEEDS_SCHEMA_VERSION := 12 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary @@ -25,6 +26,7 @@ var economic_events: Array[EconomicEventRecord] = [] var relationships: Array[RelationshipStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = [] var opportunities: Array[OpportunityStateRecord] = [] +var player: PlayerStateRecord func to_dictionary() -> Dictionary: @@ -66,7 +68,8 @@ func to_dictionary() -> Dictionary: "economic_events": event_data, "relationships": relationship_data, "event_knowledge": knowledge_data, - "opportunities": opportunity_data + "opportunities": opportunity_data, + "player": player.to_dictionary() } @@ -99,6 +102,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: ANIMAL_LEGACY_SCHEMA_VERSION, ROUTINE_LEGACY_SCHEMA_VERSION, PLAYER_RELATIONSHIP_SCHEMA_VERSION, + PLAYER_NEEDS_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -118,6 +122,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "relationships", "event_knowledge", "opportunities", + "player", ] ) ): @@ -200,6 +205,13 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var record := SimulationStateRecord.new() record.simulation = simulation_data.duplicate(true) record.village = village_record + var player_data = record_data["player"] + if not player_data is Dictionary: + return null + var player_record := PlayerStateRecord.from_dictionary(player_data) + if player_record == null: + return null + record.player = player_record var npc_ids := {} var npc_records_by_id := {} @@ -681,12 +693,23 @@ static func _is_valid_relationship_cause( and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and float(cause_event.data["amount"]) > 0.0 ) + if int(cause_event.data["actor_id"]) != SimulationIds.PLAYER_ACTOR_ID: + return false + if StringName(cause_event.data["item_id"]) != SimulationIds.RESOURCE_FOOD: + return false + if float(cause_event.data["amount"]) <= 0.0: + return false + if StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED: + return ( + StringName(cause_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID + and ( + StringName(cause_event.data["destination_id"]) + == SimulationIds.STORAGE_VILLAGE_PANTRY + ) + ) return ( - int(cause_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID - and StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED - and (StringName(cause_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY) - and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD - and float(cause_event.data["amount"]) > 0.0 + StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED + and StringName(cause_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY ) @@ -705,6 +728,8 @@ static func _is_valid_supply_resolution( ): return false if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + if actor_id == SimulationIds.PLAYER_ACTOR_ID: + return StringName(event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID return ( npc_ids.has(actor_id) and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) @@ -724,6 +749,8 @@ static func _is_valid_supply_resolution( static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: var migrated := legacy_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION + if not migrated.has("player"): + migrated["player"] = PlayerStateRecord.create_default().to_dictionary() if version <= ANIMAL_LEGACY_SCHEMA_VERSION: migrated["animals"] = [] if version == LEGACY_SCHEMA_VERSION: @@ -785,6 +812,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary ANIMAL_LEGACY_SCHEMA_VERSION, ROUTINE_LEGACY_SCHEMA_VERSION, PLAYER_RELATIONSHIP_SCHEMA_VERSION, + PLAYER_NEEDS_SCHEMA_VERSION, ] ): migrated["opportunities"] = [] diff --git a/simulation/state/player_state_record.gd b/simulation/state/player_state_record.gd new file mode 100644 index 0000000..e7d7dfb --- /dev/null +++ b/simulation/state/player_state_record.gd @@ -0,0 +1,107 @@ +class_name PlayerStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create_default() -> PlayerStateRecord: + return ( + PlayerStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "hunger": 40.0, + "energy": 100.0, + "inventory": {}, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> PlayerStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all(["hunger", "energy", "inventory"]): + return null + if not record_data["inventory"] is Dictionary: + return null + var hunger := float(record_data["hunger"]) + var energy := float(record_data["energy"]) + if not is_finite(hunger) or not is_finite(energy): + return null + var normalized_inventory := {} + 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_inventory.has(item_id) + or not is_finite(amount) + or amount < 0.0 + ): + return null + normalized_inventory[item_id] = amount + return ( + PlayerStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "hunger": clampf(hunger, 0.0, 100.0), + "energy": clampf(energy, 0.0, 100.0), + "inventory": normalized_inventory, + } + ) + ) + + +func get_hunger() -> float: + return clampf(float(data["hunger"]), 0.0, 100.0) + + +func get_energy() -> float: + return clampf(float(data["energy"]), 0.0, 100.0) + + +func set_hunger(amount: float) -> void: + data["hunger"] = clampf(amount, 0.0, 100.0) + + +func set_energy(amount: float) -> void: + data["energy"] = clampf(amount, 0.0, 100.0) + + +func get_inventory_amount(item_id: StringName) -> float: + return float(data["inventory"].get(String(item_id), 0.0)) + + +func get_carried_total() -> float: + var total := 0.0 + for amount in data["inventory"].values(): + total += float(amount) + return total + + +func add_inventory(item_id: StringName, amount: float) -> void: + if item_id.is_empty() or not is_finite(amount) or amount <= 0.0: + return + data["inventory"][String(item_id)] = get_inventory_amount(item_id) + amount + + +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 + return removed + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/player_state_record.gd.uid b/simulation/state/player_state_record.gd.uid new file mode 100644 index 0000000..a62a493 --- /dev/null +++ b/simulation/state/player_state_record.gd.uid @@ -0,0 +1 @@ +uid://dynd78rglkeji diff --git a/tests/food_shortage_opportunity_test.gd b/tests/food_shortage_opportunity_test.gd index cbfea84..cf40109 100644 --- a/tests/food_shortage_opportunity_test.gd +++ b/tests/food_shortage_opportunity_test.gd @@ -249,7 +249,11 @@ func _run() -> void: ) _check( is_equal_approx(player_manager.harvest_resource_node(berry), 1.0), - "Player harvesting should supply the real pantry through the existing command" + "Player harvesting should carry the real finite yield into player inventory" + ) + _check( + is_equal_approx(player_manager.deposit_player_inventory(SimulationIds.RESOURCE_FOOD), 1.0), + "Player deposit should supply the real pantry through the carried-inventory command" ) var player_resolved: OpportunityStateRecord = player_manager.get_latest_opportunity_for_npc( player_interested.id @@ -264,9 +268,12 @@ func _run() -> void: and player_resolution != null and ( StringName(player_resolution.data["event_type"]) - == SimulationIds.EVENT_RESOURCE_EXTRACTED + == SimulationIds.EVENT_STORAGE_DEPOSITED + ) + and int(player_resolution.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID + and ( + StringName(player_resolution.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID ) - and int(player_resolution.data["actor_id"]) == -1 and ( StringName(player_resolution.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 9bf7285..8919981 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -322,6 +322,18 @@ func _run() -> void: opportunity_bush_state.set_amount_remaining(1.0) player.global_position = opportunity_bush.interaction_point.global_position player.call("try_interact") + _check( + ( + simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD) + >= 1.0 + ), + "Player gathering should first carry the finite yield" + ) + var player_pantry := ( + main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode + ) + player.global_position = player_pantry.get_interaction_position() + player.call("try_interact") village_ui.selected_npc_index = contributor.id village_ui.call("_refresh_npc_inspector") _check( diff --git a/tests/player_citizen_loop_test.gd b/tests/player_citizen_loop_test.gd new file mode 100644 index 0000000..ea481db --- /dev/null +++ b/tests/player_citizen_loop_test.gd @@ -0,0 +1,170 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var manager := _create_manager(913) + manager.set_process(false) + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, 2.0) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + var berry := ResourceNode.new() + berry.name = "CitizenBerry" + berry.node_id = &"citizen_berry" + berry.action_id = SimulationIds.ACTION_GATHER_FOOD + berry.resource_id = SimulationIds.RESOURCE_FOOD + berry.initial_amount = 2.0 + berry.yield_per_action = 1.0 + berry.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + interaction_point.position = Vector3.ZERO + berry.add_child(interaction_point) + root.add_child(berry) + await process_frame + _check( + manager.register_resource_node(berry), + "Player citizen test should bind a real finite ResourceNode" + ) + + var initial_hunger: float = manager.get_player_state().get_hunger() + manager.simulate_tick() + _check( + is_equal_approx(manager.get_player_state().get_hunger(), initial_hunger + 0.125), + "Player hunger should advance deterministically on each simulation tick" + ) + _check( + is_equal_approx(manager.get_player_state().get_energy(), 100.0 - 0.0625), + "Player energy should drain by an exact binary fraction each tick" + ) + + _check( + is_equal_approx(manager.harvest_resource_node(berry), 1.0), + "The player should carry the first finite yield" + ) + _check( + is_equal_approx(manager.harvest_resource_node(berry), 1.0), + "The player should carry the second finite yield" + ) + _check( + is_equal_approx( + manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), 2.0 + ), + "Carried player food should be conserved across harvests" + ) + + var food_before: float = manager.get_player_state().get_hunger() + _check(manager.eat_player_food(), "The player should eat carried food") + _check( + ( + is_equal_approx(manager.get_player_state().get_hunger(), maxf(food_before - 55.0, 0.0)) + and is_equal_approx( + manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0 + ) + ), + "Eating carried food should ease hunger and consume exactly one unit" + ) + _check( + is_equal_approx(manager.deposit_player_inventory(SimulationIds.RESOURCE_FOOD), 1.0), + "The player should deposit the remaining carried food" + ) + _check( + ( + is_equal_approx( + manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0 + ) + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 3.0) + ), + "Deposit should move carried food into the pantry without creating or destroying it" + ) + + var food_before_pantry: float = pantry.get_amount(SimulationIds.RESOURCE_FOOD) + var hunger_before: float = manager.get_player_state().get_hunger() + _check(manager.eat_player_food(), "The player should eat from the pantry when carrying nothing") + _check( + ( + is_equal_approx( + pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before_pantry - 1.0 + ) + and is_equal_approx( + manager.get_player_state().get_hunger(), maxf(hunger_before - 55.0, 0.0) + ) + ), + "Pantry eating should consume one village food and ease player hunger" + ) + + manager.simulate_tick() + var mid_hunger: float = manager.get_player_state().get_hunger() + var saved_json: String = manager.serialize_state() + var restored := _create_manager(914) + _check( + restored.restore_state_from_json(saved_json), + "Player state should survive schema-v13 save and restore" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and is_equal_approx(restored.get_player_state().get_hunger(), mid_hunger) + ), + "Restore should preserve the deterministic player needs and checksum" + ) + restored.free() + + var v12_data: Dictionary = manager.create_state_record().to_dictionary() + v12_data["schema_version"] = SimulationStateRecord.PLAYER_NEEDS_SCHEMA_VERSION + v12_data.erase("player") + var migrated := SimulationStateRecord.from_dictionary(v12_data) + _check( + ( + migrated != null + and migrated.player != null + and is_equal_approx(migrated.player.get_hunger(), 40.0) + and is_equal_approx(migrated.player.get_energy(), 100.0) + and migrated.player.get_carried_total() == 0.0 + ), + "World schema v12 should migrate to v13 with a fresh default player record" + ) + + berry.queue_free() + manager.free() + _finish() + + +func _create_manager(seed_value: int = 913) -> 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] Player citizen loop passed: needs -> carry -> eat -> deposit") + 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/player_citizen_loop_test.gd.uid b/tests/player_citizen_loop_test.gd.uid new file mode 100644 index 0000000..ba581f8 --- /dev/null +++ b/tests/player_citizen_loop_test.gd.uid @@ -0,0 +1 @@ +uid://bxh6g2basq762 diff --git a/tests/player_social_consequence_test.gd b/tests/player_social_consequence_test.gd index e2163a5..6a042da 100644 --- a/tests/player_social_consequence_test.gd +++ b/tests/player_social_consequence_test.gd @@ -38,20 +38,32 @@ func _run() -> void: ) _check( is_equal_approx(manager.harvest_resource_node(berry), 1.0), - "Player harvesting should supply the real pantry" + "Player harvesting should carry the real finite yield" + ) + var carried_food: float = manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ) + _check( + is_equal_approx(carried_food, 1.0), + "Player harvesting should move the yield into carried player inventory" + ) + _check( + is_equal_approx(manager.deposit_player_inventory(SimulationIds.RESOURCE_FOOD), 1.0), + "Player deposit should supply the real pantry" ) - var supply_event := _latest_event_of_type(manager, SimulationIds.EVENT_RESOURCE_EXTRACTED) + var supply_event := _latest_event_of_type(manager, SimulationIds.EVENT_STORAGE_DEPOSITED) var supply_event_id := int(supply_event.data["event_id"]) _check( ( int(supply_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID + and (StringName(supply_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID) and ( StringName(supply_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY ) ), - "The player supply should be recorded as a pantry food extraction by the player actor" + "The player supply should be recorded as a pantry food deposit from player inventory" ) _check( ( @@ -102,6 +114,7 @@ func _run() -> void: ) restored.free() + var v12_data: Dictionary = manager.create_state_record().to_dictionary() berry.queue_free() manager.free() @@ -113,7 +126,21 @@ func _run() -> void: and migrated.simulation.get("next_opportunity_id", -1) >= 0 and not migrated.opportunities.is_empty() ), - "World schema v11 should migrate to v12 without dropping opportunity history" + "World schema v11 should migrate to v13 without dropping opportunity history" + ) + + v12_data["schema_version"] = SimulationStateRecord.PLAYER_NEEDS_SCHEMA_VERSION + v12_data.erase("player") + var v12_migrated := SimulationStateRecord.from_dictionary(v12_data) + _check( + ( + v12_migrated != null + and v12_migrated.player != null + and is_equal_approx(v12_migrated.player.get_hunger(), 40.0) + and is_equal_approx(v12_migrated.player.get_energy(), 100.0) + and v12_migrated.player.get_carried_total() == 0.0 + ), + "World schema v12 should migrate to v13 with a fresh default player record" ) _finish() diff --git a/tests/resource_node_player_parity_test.gd b/tests/resource_node_player_parity_test.gd index 3136455..6defe64 100644 --- a/tests/resource_node_player_parity_test.gd +++ b/tests/resource_node_player_parity_test.gd @@ -19,11 +19,9 @@ func _run() -> void: var bush := ( main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode ) - var tree := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/Tree_01") as ResourceNode simulation_manager.set_process(false) var bush_state: ResourceStateRecord = simulation_manager.get_resource_state(bush.node_id) - var tree_state: ResourceStateRecord = simulation_manager.get_resource_state(tree.node_id) bush_state.set_amount_remaining(1.0) _check( simulation_manager.reserve_resource(bush.node_id, 999), @@ -38,8 +36,11 @@ func _run() -> void: "Player should deplete the nearby bush" ) _check( - is_equal_approx(simulation_manager.village.food, food_before + 1.0), - "Village should receive exactly the bush's remaining food" + is_equal_approx( + simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), + 1.0 + ), + "Player harvesting should carry exactly the bush's remaining food" ) _check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation") var player_extraction: EconomicEventRecord @@ -56,8 +57,14 @@ func _run() -> void: _check(player_depletion != null, "Player depletion should create a structured event") if player_extraction != null: _check( - player_extraction.get_world_position().is_equal_approx(bush_event_position), - "Player extraction should preserve the resource interaction position" + ( + player_extraction.get_world_position().is_equal_approx(bush_event_position) + and ( + StringName(player_extraction.data["destination_id"]) + == SimulationIds.PLAYER_INVENTORY_ID + ) + ), + "Player extraction should preserve the interaction position and name the carried inventory" ) if player_depletion != null: _check( @@ -65,45 +72,54 @@ 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() - pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5) + player.global_position = pantry.get_interaction_position() + player.try_interact() + _check( + ( + is_equal_approx(simulation_manager.village.food, food_before + 1.0) + and is_equal_approx( + simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ), + 0.0 + ) + ), + "Depositing at the pantry should move carried food into the village and empty the player" + ) + + var pantry_food_before := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) + player.try_interact() + _check( + ( + pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before + and simulation_manager.get_player_state().get_hunger() < 40.0 + ), + "Player should eat from the typed pantry StorageNode and ease their hunger" + ) + + pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity()) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) bush_state.set_amount_remaining(1.0) player.global_position = bush.interaction_point.global_position 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) - ), - "Player harvesting should leave overflow at its source when storage fills" - ) - - pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 3.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() _check( - pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before, - "Player should eat from the typed pantry StorageNode" + ( + is_equal_approx(bush_state.get_amount_remaining(), 0.0) + and is_equal_approx(pantry_state.get_available_capacity(), 0.0) + and is_equal_approx( + simulation_manager.get_player_state().get_inventory_amount( + SimulationIds.RESOURCE_FOOD + ), + 1.0 + ) + ), + "Player harvesting into a full pantry should leave the unaccepted overflow carried" ) var guard_site := ( main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd index 80a84c9..cdb67b8 100644 --- a/tests/wood_shortage_opportunity_test.gd +++ b/tests/wood_shortage_opportunity_test.gd @@ -246,7 +246,11 @@ func _run() -> void: player_active_restored.free() _check( is_equal_approx(player_manager.harvest_resource_node(tree), 1.0), - "Player tree extraction should supply the authoritative woodpile" + "Player tree extraction should carry the authoritative wood yield" + ) + _check( + is_equal_approx(player_manager.deposit_player_inventory(SimulationIds.RESOURCE_WOOD), 1.0), + "Player wood deposit should supply the authoritative woodpile" ) var player_resolved: OpportunityStateRecord = player_manager.get_latest_opportunity_for_npc( player_actor.id @@ -258,10 +262,10 @@ func _run() -> void: ( player_resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED and ( - StringName(player_event.data["event_type"]) - == SimulationIds.EVENT_RESOURCE_EXTRACTED + StringName(player_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED ) - and int(player_event.data["actor_id"]) == -1 + and int(player_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID + and (StringName(player_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID) and ( StringName(player_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE diff --git a/world/ui/player_status_hud.gd b/world/ui/player_status_hud.gd new file mode 100644 index 0000000..af09a3c --- /dev/null +++ b/world/ui/player_status_hud.gd @@ -0,0 +1,46 @@ +class_name PlayerStatusHud +extends Control + +@export var simulation_manager: Node + +@onready var status_label: Label = $StatusLabel + +var cached_text := "" + + +func _ready() -> void: + visible = true + _refresh(true) + + +func _process(_delta: float) -> void: + _refresh(false) + + +func _refresh(force: bool = false) -> void: + if simulation_manager == null or not simulation_manager.has_method("get_player_state"): + return + var player_state: PlayerStateRecord = simulation_manager.get_player_state() + var carried := _carried_text(player_state) + var next_text := ( + "Hunger %.0f · Energy %.0f · Carrying %s" + % [player_state.get_hunger(), player_state.get_energy(), carried] + ) + if not force and next_text == cached_text: + return + cached_text = next_text + status_label.text = next_text + + +func _carried_text(player_state: PlayerStateRecord) -> String: + var entries: Array[String] = [] + for raw_item_id in player_state.data["inventory"]: + var item_id := String(raw_item_id) + var amount := player_state.get_inventory_amount(StringName(item_id)) + if amount <= 0.0: + continue + entries.append("%.0f %s" % [amount, StringName(item_id).to_lower()]) + if entries.is_empty(): + return "nothing" + entries.sort() + return " · ".join(entries) diff --git a/world/ui/player_status_hud.gd.uid b/world/ui/player_status_hud.gd.uid new file mode 100644 index 0000000..b5bf481 --- /dev/null +++ b/world/ui/player_status_hud.gd.uid @@ -0,0 +1 @@ +uid://cgur3e3k1l3iy