From 7d410d8193cad0b7036578af13b7120b88e88580 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 30 Jul 2026 23:27:46 +0200 Subject: [PATCH] feat: add exact player context actions --- docs/ACTION_SYSTEM_ARCHITECTURE.md | 15 ++ docs/BUILD_IN_PUBLIC_PLAN.md | 16 +- docs/LEARNING_ROADMAP.md | 24 +- docs/PROJECT_CONTEXT.md | 21 +- main.tscn | 66 +++++ player/PlayerInteractionResult.gd | 57 +++++ player/PlayerInteractionResult.gd.uid | 1 + player/player.gd | 236 +++++++++++++---- tests/player_context_interaction_test.gd | 254 +++++++++++++++++++ tests/player_context_interaction_test.gd.uid | 1 + world/ui/player_interaction_hud.gd | 137 ++++++++++ world/ui/player_interaction_hud.gd.uid | 1 + 12 files changed, 762 insertions(+), 67 deletions(-) create mode 100644 player/PlayerInteractionResult.gd create mode 100644 player/PlayerInteractionResult.gd.uid create mode 100644 tests/player_context_interaction_test.gd create mode 100644 tests/player_context_interaction_test.gd.uid create mode 100644 world/ui/player_interaction_hud.gd create mode 100644 world/ui/player_interaction_hud.gd.uid diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 04736cd..08785bb 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -149,6 +149,21 @@ These collaborators are `RefCounted` rule services, not additional scene-tree managers. Further decomposition should follow measured pressure and a proven gameplay consumer. +## Player context-interaction contract + +`Player.get_interaction_context()` derives one ephemeral +`PlayerInteractionResult` in the same priority order used by `E`: hungry +animal, usable finite resource, guard site, study site, then pantry. The result +contains the exact stable target ID, action, display name, prompt copy, +availability, and loaded target reference. `try_interact()` executes that +single resolved result rather than maintaining a second target map. + +`PlayerInteractionHud` formats the result and transient outcome feedback. It +does not select targets, mutate simulation state, or enter the save record. +State restore clears active feedback and re-derives the prompt from current +simulation and loaded-world facts. The HUD is separate from development +overlays so the embodied action remains legible in cinematic mode. + ## Active-position contract NpcVisual emits active position changes after successful movement. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 89700a6..05ac340 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -817,14 +817,20 @@ Completed: preserves two exact reservations through save/restore, and feeds only the named target. Private shelter ownership remains loaded-world context; no herd system, animal index, or schema bump was introduced. +43. `Jajce Context Action 10`: the existing `E` interactions now derive one + exact ephemeral result shared by prompt and execution. A restrained, + cardless bottom-center HUD names the nearest hungry goat, shows real pantry + availability, reports success or blockage, remains visible in cinematic + mode, and also covers existing resource, pantry, guard, and study actions. + Restore discards transient feedback and re-derives context from live facts. Next: -1. Playtest the named pair and record one concrete gameplay or scaling need - before extending the animal model. -2. Keep breeding, products, herd scheduling, animal spatial indexing, and - active/abstract simulation LOD deferred unless that evidence requires one - of them; otherwise return to the broader milestone sequence. +1. Make NPC feeding visibly collect one real pantry food into the caretaker's + existing inventory/carried-food presentation before delivery to the exact + reserved goat. +2. Prove conservation, claim continuity, and save/restore during both travel + legs; keep herd, product, indexing, and LOD systems deferred. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 6a492d9..6774127 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -947,14 +947,28 @@ Schema-v11 restore preserves both claim pairings and checksum. Loaded routine sites now distinguish private shelter ownership from shared species context without adding that authored-world fact to saved simulation state. -The immediate next step is a playtest-and-measure gate, not a herd abstraction. -Keep loaded-animal discovery linear and use the named pair to identify one -concrete gameplay need before adding breeding, products, herd scheduling, -animal indexing, or active/abstract animal LOD. If no such need appears, resume -the broader milestone sequence instead of adding animal framework by momentum. +The named-pair playtest gate exposed and closed its first embodied-use gap. +`PlayerInteractionResult` now derives one exact, ephemeral context from the +same priority and stable target used by `E`: named animal care, finite-resource +harvest, pantry eating, guard work, or study. A cardless bottom-center prompt +shows the action and real availability, remains visible in cinematic mode, and +briefly reports success or blockage. Restore clears transient feedback and +re-derives the current prompt without adding saved state or drawing RNG. + +The next bounded slice should make NPC animal care physically honest: +`village_pantry -> NPC inventory -> exact named goat`. A caretaker should keep +the same animal claim, collect one real food into the existing carried-food +presentation, and deliver it without duplication across contention or +mid-route restore. Keep breeding, products, herd scheduling, animal indexing, +and active/abstract animal LOD deferred. Recently completed: +- `Jajce Context Action 10`: one ephemeral result now keeps the displayed `E` + prompt and executed stable target aligned across existing player + interactions. Named goat care shows exact pantry availability and transient + success/blockage, survives cinematic mode, and re-derives after restore + without persistent UI state. - `Jajce Goat Pair 09`: named goat Zora adds a second independent animal record and private shelter while sharing Dunja's pasture. Stable nearest targeting, simultaneous exact reservations, isolated feed mutation and diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 40463de..786ac55 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -228,6 +228,10 @@ plugin content, not game architecture. - Pressing `E` near a berry bush or tree extracts its configured yield into the village through the same `ResourceNode` contract used by NPCs. - Guard, study, rest, and food interactions now use typed world sites. +- A cardless bottom-center `E` prompt derives the exact current animal, + resource, storage, or activity target and its availability. Named goat care + reports pantry cost plus transient success or blockage, remains in cinematic + mode, and never enters saved state. - `Escape` releases captured mouse input. ### Village simulation @@ -478,16 +482,19 @@ than duplicating these responsibilities across additional manager nodes. These implement camera-relative character movement, physical interaction proximity checks, ResourceNode harvesting, mouse capture, and the elevated -follow camera. +follow camera. `PlayerInteractionResult` is the ephemeral boundary shared by +the current `E` prompt and execution, so UI copy names the exact stable target +that receives the command. ### `world/ui/ui.gd` The UI subscribes to village, task, event, knowledge, relationship, and opportunity changes. It formats aggregate state and resolves known facts, relationship causes, and opportunity lifecycle details through manager queries -without owning or recomputing simulation facts. `village_whisper_hud.gd` is a -separate transient player layer that formats the same authoritative signals and -remains independent of the development overlays. +without owning or recomputing simulation facts. `village_whisper_hud.gd` and +`player_interaction_hud.gd` are separate transient player layers that format +authoritative signals or the current exact context and remain independent of +the development overlays. ## Current runtime flow @@ -1005,8 +1012,10 @@ Dunja's pasture, nearest loaded care targeting supports two simultaneous exact claims, and feeding one goat cannot mutate the other. World-schema v11 preserves both claim pairings without serializing shelter ownership. Do not generalize animal discovery or introduce herd scheduling, products, breeding, -or active/abstract animal LOD until playtest or measurement exposes a concrete -need. +or active/abstract animal LOD. The named-pair gate instead produced one +Milestone 9 context-action result shared by prompt and execution, with exact +pantry feedback and no persistent UI state. The next animal-facing proof should +make NPC care visibly carry one real pantry food to its exact reserved goat. The remaining simulation-garden target still aims for: diff --git a/main.tscn b/main.tscn index eb97783..876ceb5 100644 --- a/main.tscn +++ b/main.tscn @@ -13,6 +13,7 @@ [ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"] [ext_resource type="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"] [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"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] radius = 0.4 @@ -127,6 +128,71 @@ offset_right = 940.0 offset_bottom = 66.0 script = ExtResource("12_timedial") +[node name="PlayerInteractionLayer" type="CanvasLayer" parent="."] +layer = 2 + +[node name="PlayerInteractionHud" type="Control" parent="PlayerInteractionLayer" node_paths=PackedStringArray("player", "simulation_manager")] +visible = false +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -290.0 +offset_top = -108.0 +offset_right = 290.0 +offset_bottom = -42.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +script = ExtResource("16_interaction") +player = NodePath("../../Player") +simulation_manager = NodePath("../../SimulationManager") + +[node name="Accent" type="ColorRect" parent="PlayerInteractionLayer/PlayerInteractionHud"] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 2.0 +grow_horizontal = 2 +mouse_filter = 2 +color = Color(0.9, 0.63, 0.28, 0.95) + +[node name="Copy" type="VBoxContainer" parent="PlayerInteractionLayer/PlayerInteractionHud"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = 8.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_constants/separation = 2 +alignment = 1 + +[node name="Action" type="Label" parent="PlayerInteractionLayer/PlayerInteractionHud/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.98, 0.92, 0.79, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94) +theme_override_colors/font_shadow_color = Color(0.02, 0.01, 0.005, 0.7) +theme_override_constants/outline_size = 7 +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 20 +text = "E · Interact" +horizontal_alignment = 1 + +[node name="Detail" type="Label" parent="PlayerInteractionLayer/PlayerInteractionHud/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.85, 0.77, 0.64, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9) +theme_override_constants/outline_size = 5 +theme_override_font_sizes/font_size = 13 +text = "Context" +horizontal_alignment = 1 + [node name="VillageWhisperLayer" type="CanvasLayer" parent="."] layer = 3 diff --git a/player/PlayerInteractionResult.gd b/player/PlayerInteractionResult.gd new file mode 100644 index 0000000..ee3f3a2 --- /dev/null +++ b/player/PlayerInteractionResult.gd @@ -0,0 +1,57 @@ +class_name PlayerInteractionResult +extends RefCounted + +const KIND_ANIMAL := &"animal" +const KIND_RESOURCE := &"resource" +const KIND_PANTRY := &"pantry" +const KIND_GUARD := &"guard" +const KIND_STUDY := &"study" + +var kind: StringName +var action_id: StringName +var target_id: StringName +var display_name: String +var prompt_text: String +var detail_text: String +var blocked_reason: String +var target_node: Node + + +func _init( + interaction_kind: StringName, + interaction_action_id: StringName, + interaction_target_id: StringName, + interaction_display_name: String, + interaction_prompt_text: String, + interaction_detail_text: String, + interaction_target_node: Node, + interaction_blocked_reason := "" +) -> void: + kind = interaction_kind + action_id = interaction_action_id + target_id = interaction_target_id + display_name = interaction_display_name + prompt_text = interaction_prompt_text + detail_text = interaction_detail_text + target_node = interaction_target_node + blocked_reason = interaction_blocked_reason + + +func is_available() -> bool: + return blocked_reason.is_empty() + + +func cache_key() -> String: + return ( + "|" + . join( + [ + String(kind), + String(action_id), + String(target_id), + prompt_text, + detail_text, + blocked_reason, + ] + ) + ) diff --git a/player/PlayerInteractionResult.gd.uid b/player/PlayerInteractionResult.gd.uid new file mode 100644 index 0000000..f15c137 --- /dev/null +++ b/player/PlayerInteractionResult.gd.uid @@ -0,0 +1 @@ +uid://bw2ng0q0wm030 diff --git a/player/player.gd b/player/player.gd index c249ca2..3408bf8 100644 --- a/player/player.gd +++ b/player/player.gd @@ -1,5 +1,7 @@ extends CharacterBody3D +signal interaction_feedback(heading: String, message: String, succeeded: bool) + @export var move_speed := 7.0 @export var acceleration := 18.0 @export var rotation_speed := 12.0 @@ -56,73 +58,205 @@ func _unhandled_input(event: InputEvent) -> void: func try_interact() -> void: + var context := get_interaction_context() + if context == null: + return + + match context.kind: + PlayerInteractionResult.KIND_ANIMAL: + _execute_animal_interaction(context) + PlayerInteractionResult.KIND_RESOURCE: + _execute_resource_interaction(context) + PlayerInteractionResult.KIND_GUARD: + simulation_manager.add_safety(3.0) + interaction_feedback.emit( + "Village guarded", "Safety increased through real guard-post work.", true + ) + PlayerInteractionResult.KIND_STUDY: + simulation_manager.add_knowledge(2.0) + interaction_feedback.emit( + "Knowledge shared", "Village knowledge increased at the study desk.", true + ) + PlayerInteractionResult.KIND_PANTRY: + _execute_pantry_interaction(context) + + +func get_interaction_context() -> PlayerInteractionResult: if simulation_manager == null: - return - - if try_feed_animal(): - return - - if try_harvest_resource_node(): - return - + return null + var animal := _find_feed_animal() + if animal != null: + return _build_animal_context(animal) + var resource := _find_resource_node() + if resource != null: + return _build_resource_context(resource) if is_near_activity_site(guard_site): - simulation_manager.add_safety(3.0) - print("Player helped guard the village.") - elif is_near_activity_site(study_site): - simulation_manager.add_knowledge(2.0) - print("Player shared knowledge.") - elif is_near_storage(pantry_storage): - simulation_manager.eat_food(stomach_capacity_for_food) - print("Player ate food from the village supply.") + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_GUARD, + SimulationIds.ACTION_PATROL, + guard_site.site_id, + guard_site.display_name, + "Help guard the village", + "Work here · +3 safety", + guard_site + ) + if is_near_activity_site(study_site): + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_STUDY, + SimulationIds.ACTION_STUDY, + study_site.site_id, + study_site.display_name, + "Share knowledge", + "Work here · +2 knowledge", + study_site + ) + if is_near_storage(pantry_storage): + return _build_pantry_context() + return null -func try_feed_animal() -> bool: +func _find_feed_animal() -> AnimalNode: if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null: push_error("Player: SimulationManager has no animal-care service") - return false - var node: AnimalNode = simulation_manager.animal_care.find_node_for_player( - global_position, interaction_range - ) - if node == null: - return false - if not simulation_manager.has_method("feed_animal"): - push_error("Player: SimulationManager cannot feed AnimalNodes") - return true - if simulation_manager.feed_animal(node.animal_id, -1): - print("Player fed %s one food from the village pantry." % node.display_name) - else: - print("%s is hungry, but the pantry has no food to spare." % node.display_name) - return true + return null + return simulation_manager.animal_care.find_node_for_player(global_position, interaction_range) -func try_harvest_resource_node() -> bool: +func _find_resource_node() -> ResourceNode: if not simulation_manager.has_method("find_resource_node_for_player"): push_error("Player: SimulationManager cannot find ResourceNodes") - return false - var node: ResourceNode = simulation_manager.find_resource_node_for_player( - global_position, interaction_range + return null + return simulation_manager.find_resource_node_for_player(global_position, interaction_range) + + +func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult: + var cost := _get_animal_feed_cost() + var pantry_food := _get_pantry_food() + var blocked_reason := "" + var detail := "%.0f pantry food · %.0f available" % [cost, pantry_food] + if pantry_food < cost: + if pantry_food <= 0.0: + blocked_reason = "The village pantry is empty." + detail = "Pantry empty · needs %.0f food" % cost + else: + blocked_reason = "The village pantry does not have enough food." + detail = "Needs %.0f pantry food · %.1f available" % [cost, pantry_food] + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_ANIMAL, + SimulationIds.ACTION_FEED_ANIMAL, + node.animal_id, + node.display_name, + "Feed %s" % node.display_name, + detail, + node, + blocked_reason ) - if node == null: - return false - if not node.is_enabled(): - print("%s is unavailable." % node.node_id) - return true - if node.is_depleted(): - print("%s is depleted." % node.node_id) - return true +func _build_resource_context(node: ResourceNode) -> PlayerInteractionResult: + var definition := SimulationDefinitions.get_action(node.action_id) + var prompt := ( + definition.display_name if definition != null else String(node.action_id).capitalize() + ) + var display_name := String(node.node_id).replace("_", " ").capitalize() + 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 + ) - if not simulation_manager.has_method("harvest_resource_node"): - push_error("Player: SimulationManager cannot harvest ResourceNodes") - return true +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 detail := "%.0f food available" % pantry_food + if not blocked_reason.is_empty(): + detail = "Pantry empty" + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_PANTRY, + SimulationIds.ACTION_EAT, + SimulationIds.STORAGE_VILLAGE_PANTRY, + "Village pantry", + "Eat from the pantry", + detail, + pantry_storage, + blocked_reason + ) + + +func _execute_animal_interaction(context: PlayerInteractionResult) -> void: + var node := context.target_node as AnimalNode + if node == null or not context.is_available(): + interaction_feedback.emit( + "%s is still hungry" % context.display_name, + ( + context.blocked_reason + if not context.blocked_reason.is_empty() + else "Animal care is unavailable." + ), + false + ) + return + if not simulation_manager.has_method("feed_animal"): + push_error("Player: SimulationManager cannot feed AnimalNodes") + return + if simulation_manager.feed_animal(context.target_id, -1): + interaction_feedback.emit( + "%s is fed" % context.display_name, + "%.0f food moved from the village pantry." % _get_animal_feed_cost(), + true + ) + return + interaction_feedback.emit( + "%s is still hungry" % context.display_name, "Animal care could not be completed.", false + ) + + +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") + return var extracted: float = simulation_manager.harvest_resource_node(node) - if extracted > 0.0: - print("Player gathered %s %s from %s." % [extracted, node.resource_id, node.node_id]) - else: - print("%s could not be harvested." % node.node_id) - return true + if extracted <= 0.0: + interaction_feedback.emit( + "%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], + true + ) + + +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 + ) + + +func _get_animal_feed_cost() -> float: + var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) + return definition.completion_cost_amount if definition != null else 1.0 + + +func _get_pantry_food() -> float: + if not simulation_manager.has_method("get_pantry"): + return 0.0 + var pantry: StorageStateRecord = simulation_manager.get_pantry() + return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0 func is_near_storage(storage_node: StorageNode) -> bool: diff --git a/tests/player_context_interaction_test.gd b/tests/player_context_interaction_test.gd new file mode 100644 index 0000000..75e5e21 --- /dev/null +++ b/tests/player_context_interaction_test.gd @@ -0,0 +1,254 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + var manager: Node = main_scene.get_node("SimulationManager") + manager.set_process(false) + var player := main_scene.get_node("Player") + player.set_physics_process(false) + for _frame in 3: + await physics_frame + + var dunja_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_DUNJA) + var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA) + var dunja_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA) + var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA) + var hud := main_scene.get_node_or_null("PlayerInteractionLayer/PlayerInteractionHud") + _check( + ( + dunja_node != null + and zora_node != null + and dunja_state != null + and zora_state != null + and hud != null + ), + "The real main scene should expose both named goats and the player interaction HUD", + ) + if ( + dunja_node == null + or zora_node == null + or dunja_state == null + or zora_state == null + or hud == null + ): + _finish() + return + + dunja_node.set_physics_process(false) + zora_node.set_physics_process(false) + dunja_state.set_hunger(72.0) + zora_state.set_hunger(72.0) + var dunja_interaction_position := dunja_node.get_interaction_position() + var zora_interaction_position := zora_node.get_interaction_position() + var tie_position := (dunja_interaction_position + zora_interaction_position) * 0.5 + var pair_range := dunja_interaction_position.distance_to(zora_interaction_position) + 0.5 + player.set("interaction_range", pair_range) + + player.global_position = zora_interaction_position + var nearest_context := player.call("get_interaction_context") as PlayerInteractionResult + _check( + ( + nearest_context != null + and nearest_context.kind == PlayerInteractionResult.KIND_ANIMAL + and nearest_context.action_id == SimulationIds.ACTION_FEED_ANIMAL + and nearest_context.target_id == SimulationIds.ANIMAL_ZORA + and nearest_context.display_name == "Zora" + and nearest_context.target_node == zora_node + and nearest_context.is_available() + and "Zora" in nearest_context.prompt_text + ), + "With both hungry goats in range, the context should name exact nearest goat Zora", + ) + hud.call("refresh_prompt") + var action_label := hud.get_node("Copy/Action") as Label + var detail_label := hud.get_node("Copy/Detail") as Label + _check( + nearest_context != null and action_label != null and detail_label != null, + "The prompt proof needs a resolved context and both public HUD labels", + ) + if nearest_context == null or action_label == null or detail_label == null: + _finish() + return + _check( + ( + nearest_context.prompt_text in action_label.text + and detail_label.text == nearest_context.detail_text + ), + "The HUD prompt should render the same exact context that execution will use", + ) + + player.global_position = tie_position + var tie_context := player.call("get_interaction_context") as PlayerInteractionResult + _check( + ( + tie_context != null + and tie_position.distance_to(dunja_interaction_position) <= pair_range + and tie_position.distance_to(zora_interaction_position) <= pair_range + and tie_context.target_id == SimulationIds.ANIMAL_DUNJA + and tie_context.display_name == "Dunja" + and tie_context.target_node == dunja_node + ), + "An equal-distance pair should resolve by stable animal ID, choosing Dunja", + ) + + player.global_position = zora_interaction_position + var execution_context := player.call("get_interaction_context") as PlayerInteractionResult + var pantry: StorageStateRecord = manager.get_pantry() + var pantry_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD) + var dunja_hunger_before := dunja_state.get_hunger() + var zora_hunger_before := zora_state.get_hunger() + var events_before: int = manager.economic_events.size() + player.call("try_interact") + var feed_event := _latest_feed_event(manager) + _check( + ( + execution_context != null + and execution_context.target_id == SimulationIds.ANIMAL_ZORA + and is_equal_approx(dunja_state.get_hunger(), dunja_hunger_before) + and is_equal_approx( + zora_state.get_hunger(), zora_hunger_before - AnimalStateRecord.HUNGER_RELIEF + ) + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before - 1.0) + and manager.economic_events.size() == events_before + 1 + ), + "Interaction should feed the prompted Zora only and consume one real pantry food", + ) + _check( + ( + feed_event != null + and int(feed_event.data["actor_id"]) == -1 + and StringName(feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY + and StringName(feed_event.data["destination_id"]) == SimulationIds.ANIMAL_ZORA + and StringName(feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and is_equal_approx(float(feed_event.data["amount"]), 1.0) + and feed_event.get_world_position().is_equal_approx(zora_state.get_position()) + ), + "The player feed event should preserve its exact pantry, named target, amount, and position", + ) + _check( + bool(hud.get("feedback_active")) and bool(hud.get("feedback_succeeded")), + "A successful exact interaction should surface transient positive HUD feedback", + ) + + dunja_state.set_hunger(72.0) + zora_state.set_hunger(72.0) + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + player.set("interaction_range", 100.0) + player.global_position = zora_node.get_interaction_position() + var blocked_context := player.call("get_interaction_context") as PlayerInteractionResult + hud.call("refresh_prompt") + _check( + ( + blocked_context != null + and blocked_context.target_id == SimulationIds.ANIMAL_ZORA + and blocked_context.display_name == "Zora" + and not blocked_context.is_available() + and not blocked_context.blocked_reason.is_empty() + and "Zora" in (blocked_context.prompt_text + blocked_context.detail_text) + and ( + "pantry" + in (blocked_context.blocked_reason + blocked_context.detail_text).to_lower() + ) + and blocked_context.prompt_text in action_label.text + and detail_label.text == blocked_context.detail_text + ), + "An empty pantry should show a named blocked Zora prompt before input", + ) + + var blocked_dunja_hunger := dunja_state.get_hunger() + var blocked_zora_hunger := zora_state.get_hunger() + var blocked_event_count: int = manager.economic_events.size() + var blocked_safety: float = manager.village.safety + var blocked_knowledge: float = manager.village.knowledge + player.call("try_interact") + _check( + ( + is_equal_approx(dunja_state.get_hunger(), blocked_dunja_hunger) + and is_equal_approx(zora_state.get_hunger(), blocked_zora_hunger) + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0) + and manager.economic_events.size() == blocked_event_count + and is_equal_approx(manager.village.safety, blocked_safety) + and is_equal_approx(manager.village.knowledge, blocked_knowledge) + ), + "A blocked animal interaction should consume input without mutation, events, or fallthrough", + ) + _check( + bool(hud.get("feedback_active")) and not bool(hud.get("feedback_succeeded")), + "The blocked exact interaction should surface transient negative HUD feedback", + ) + + var blocked_json: String = manager.serialize_state() + dunja_state.set_hunger(20.0) + zora_state.set_hunger(20.0) + pantry.deposit(SimulationIds.RESOURCE_FOOD, 2.0) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + _check( + manager.restore_state_from_json(blocked_json), + "The blocked two-goat context should restore through the complete state schema", + ) + dunja_state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA) + zora_state = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA) + pantry = manager.get_pantry() + var restored_context := player.call("get_interaction_context") as PlayerInteractionResult + _check( + ( + not bool(hud.get("feedback_active")) + and restored_context != null + and restored_context.target_id == SimulationIds.ANIMAL_ZORA + and not restored_context.is_available() + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0) + and is_equal_approx(dunja_state.get_hunger(), blocked_dunja_hunger) + and is_equal_approx(zora_state.get_hunger(), blocked_zora_hunger) + and restored_context.prompt_text in action_label.text + and detail_label.text == restored_context.detail_text + ), + "Restore should clear transient feedback and re-derive the named blocked prompt", + ) + + var demo_controller := main_scene.get_node("DemoController") + var debug_ui := main_scene.get_node("UI") as CanvasLayer + demo_controller.call("set_debug_overlay_visible", false) + _check( + ( + not debug_ui.visible + and bool(hud.get("visible")) + and action_label.visible + and not action_label.text.is_empty() + ), + "The player interaction HUD should remain visible when development overlays are hidden", + ) + + _finish() + + +func _latest_feed_event(manager: Node) -> EconomicEventRecord: + for index in range(manager.economic_events.size() - 1, -1, -1): + var event := manager.economic_events[index] as EconomicEventRecord + if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED: + return event + return null + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Player context interaction passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/player_context_interaction_test.gd.uid b/tests/player_context_interaction_test.gd.uid new file mode 100644 index 0000000..dbcfba4 --- /dev/null +++ b/tests/player_context_interaction_test.gd.uid @@ -0,0 +1 @@ +uid://47l78j2djkv0 diff --git a/world/ui/player_interaction_hud.gd b/world/ui/player_interaction_hud.gd new file mode 100644 index 0000000..1d75911 --- /dev/null +++ b/world/ui/player_interaction_hud.gd @@ -0,0 +1,137 @@ +class_name PlayerInteractionHud +extends Control + +const FEEDBACK_SECONDS := 2.0 +const ENTER_OFFSET := 7.0 +const ACCENT_READY := Color(0.9, 0.63, 0.28, 0.95) +const ACCENT_SUCCESS := Color(0.55, 0.78, 0.38, 0.95) +const ACCENT_BLOCKED := Color(0.82, 0.4, 0.24, 0.95) + +@export var player: Node +@export var simulation_manager: Node + +@onready var accent: ColorRect = $Accent +@onready var action_label: Label = $Copy/Action +@onready var detail_label: Label = $Copy/Detail + +var resting_position: Vector2 +var motion_tween: Tween +var current_context_key := "" +var feedback_version := 0 +var feedback_active := false +var feedback_succeeded := false + + +func _ready() -> void: + resting_position = position + _reset_transient_state() + if player == null or not player.has_signal("interaction_feedback"): + push_error("PlayerInteractionHud: player interaction feedback is unavailable") + return + player.interaction_feedback.connect(_on_interaction_feedback) + if simulation_manager != null and simulation_manager.has_signal("state_restored"): + simulation_manager.state_restored.connect(_on_state_restored) + call_deferred("refresh_prompt", true) + + +func _process(_delta: float) -> void: + if not feedback_active: + refresh_prompt() + + +func refresh_prompt(force: bool = false) -> void: + if player == null or not player.has_method("get_interaction_context"): + _hide_prompt() + return + var context: PlayerInteractionResult = player.get_interaction_context() + if context == null: + if force or not current_context_key.is_empty(): + current_context_key = "" + _hide_prompt() + return + var next_key := context.cache_key() + if not force and next_key == current_context_key: + return + current_context_key = next_key + feedback_succeeded = false + _present( + "E · %s" % context.prompt_text, + context.detail_text, + ACCENT_READY if context.is_available() else ACCENT_BLOCKED, + true + ) + + +func _on_interaction_feedback(heading: String, message: String, succeeded: bool) -> void: + feedback_version += 1 + var version := feedback_version + feedback_active = true + feedback_succeeded = succeeded + current_context_key = "" + _present(heading, message, ACCENT_SUCCESS if succeeded else ACCENT_BLOCKED, true) + _resume_prompt_after_feedback(version) + + +func _resume_prompt_after_feedback(version: int) -> void: + await get_tree().create_timer(FEEDBACK_SECONDS).timeout + if version != feedback_version or not is_inside_tree(): + return + feedback_active = false + refresh_prompt(true) + + +func _on_state_restored() -> void: + _reset_transient_state() + refresh_prompt(true) + + +func _present(heading: String, detail: String, accent_color: Color, animate: bool) -> void: + if motion_tween != null: + motion_tween.kill() + action_label.text = heading + detail_label.text = detail + accent.color = accent_color + visible = true + accent.pivot_offset = accent.size * 0.5 + if not animate: + position = resting_position + modulate.a = 1.0 + accent.scale.x = 1.0 + return + position = resting_position + Vector2(0.0, ENTER_OFFSET) + modulate.a = 0.0 + accent.scale.x = 0.18 + motion_tween = create_tween().set_parallel(true) + motion_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + motion_tween.tween_property(self, "position", resting_position, 0.22) + motion_tween.tween_property(self, "modulate:a", 1.0, 0.18) + motion_tween.tween_property(accent, "scale:x", 1.0, 0.28) + + +func _hide_prompt() -> void: + if not visible: + return + if motion_tween != null: + motion_tween.kill() + motion_tween = create_tween().set_parallel(true) + motion_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + motion_tween.tween_property(self, "modulate:a", 0.0, 0.16) + motion_tween.tween_property(self, "position:y", resting_position.y - 3.0, 0.16) + var hiding_tween := motion_tween + await hiding_tween.finished + if motion_tween == hiding_tween and current_context_key.is_empty() and not feedback_active: + visible = false + + +func _reset_transient_state() -> void: + feedback_version += 1 + feedback_active = false + feedback_succeeded = false + current_context_key = "" + if motion_tween != null: + motion_tween.kill() + visible = false + position = resting_position + modulate.a = 0.0 + if accent != null: + accent.scale.x = 1.0 diff --git a/world/ui/player_interaction_hud.gd.uid b/world/ui/player_interaction_hud.gd.uid new file mode 100644 index 0000000..d3dc186 --- /dev/null +++ b/world/ui/player_interaction_hud.gd.uid @@ -0,0 +1 @@ +uid://caga5h0ri6vei