diff --git a/main.tscn b/main.tscn index 3f34b07..1fa8c69 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/quest_journal_hud.gd" id="18_quest_journal"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] radius = 0.4 @@ -390,6 +391,86 @@ text = "The pantry is empty." horizontal_alignment = 1 vertical_alignment = 1 +[node name="QuestJournalLayer" type="CanvasLayer" parent="."] +layer = 4 + +[node name="QuestJournal" type="Control" parent="QuestJournalLayer" node_paths=PackedStringArray("simulation_manager")] +visible = false +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -360.0 +offset_top = -150.0 +offset_right = -16.0 +offset_bottom = -20.0 +grow_horizontal = 0 +grow_vertical = 0 +mouse_filter = 2 +script = ExtResource("18_quest_journal") +simulation_manager = NodePath("../../SimulationManager") + +[node name="Accent" type="ColorRect" parent="QuestJournalLayer/QuestJournal"] +layout_mode = 1 +anchors_preset = 9 +anchor_bottom = 1.0 +offset_right = 2.0 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.55, 0.42, 0.2, 0.9) + +[node name="Copy" type="VBoxContainer" parent="QuestJournalLayer/QuestJournal"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 12.0 +offset_top = 8.0 +mouse_filter = 2 +theme_override_constants/separation = 2 +alignment = 2 + +[node name="Standing" type="Label" parent="QuestJournalLayer/QuestJournal/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_constants/outline_size = 6 +theme_override_font_sizes/font_size = 18 +text = "Standing · Stranger · 0/100" + +[node name="QuestKicker" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.93, 0.7, 0.36, 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 = 11 +text = "ACTIVE QUEST" + +[node name="Quest" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.9, 0.84, 0.7, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9) +theme_override_constants/outline_size = 4 +theme_override_font_sizes/font_size = 13 +text = "Amina · bring 1 food to the village pantry" +autowrap_mode = 2 + +[node name="Thanks" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"] +visible = false +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.95, 0.76, 0.42, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94) +theme_override_constants/outline_size = 5 +theme_override_font_sizes/font_size = 13 +text = "Gratitude" +horizontal_alignment = 1 +autowrap_mode = 2 + [node name="DemoController" type="Node" parent="." unique_id=644713371 node_paths=PackedStringArray("ui", "active_npcs_parent", "simulation_manager", "world_view_manager", "camera_rig", "player", "pantry_storage", "crisis_caption")] script = ExtResource("13_demo") ui = NodePath("../UI") diff --git a/tests/quest_journal_surface_test.gd b/tests/quest_journal_surface_test.gd new file mode 100644 index 0000000..d1ca5e6 --- /dev/null +++ b/tests/quest_journal_surface_test.gd @@ -0,0 +1,173 @@ +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 + for _frame in 10: + await physics_frame + + var manager: Node = main_scene.get_node("SimulationManager") + var view: Node = main_scene.get_node("WorldViewManager") + var player: CharacterBody3D = main_scene.get_node("Player") + var journal := main_scene.get_node("QuestJournalLayer/QuestJournal") + manager.set_process(false) + player.set_physics_process(false) + _freeze_visuals(view) + + var actor: SimNPC = manager.npcs[0] + var interested: SimNPC = manager.npcs[2] + _set_pantry_amount(manager, 1.0) + for npc in manager.npcs: + _set_loaded_position(manager, view, npc, Vector3(40.0 + npc.id * 10.0, 0.0, 30.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, "An unassisted known need should generate a player quest") + if quest == null: + manager.set_process(true) + _finish() + return + + _check( + ( + quest.get_requester_npc_id() == interested.id + and journal.visible + and "Standing" in (journal.get_node("Copy/Standing") as Label).text + and "Stranger" in (journal.get_node("Copy/Standing") as Label).text + and interested.npc_name in (journal.get_node("Copy/Quest") as Label).text + ), + "The quest journal should surface the standing tier and the named need", + ) + + var food_node: ResourceNode = _find_food_node(manager) + _check(food_node != null, "The runtime proof needs a stocked player-usable food source") + if food_node == null: + _finish() + return + var checksum_before: String = manager.get_state_checksum() + _check( + manager.harvest_resource_node(food_node) > 0.0, + "The player should resolve the quest through the ordinary finite harvest" + ) + _check( + ( + manager.get_active_player_quest() == null + and is_equal_approx( + manager.get_player_standing().get_standing(), quest.get_standing_reward() + ) + and is_equal_approx(manager.get_player_standing().get_gratitude(interested.id), 0.1) + and manager.get_state_checksum() != checksum_before + ), + "Resolving the need should complete the quest and grant standing + gratitude", + ) + + var saved_json: String = manager.serialize_state() + var restored := _create_restored_manager(saved_json) + _check( + ( + restored != null + and restored.get_state_checksum() == manager.get_state_checksum() + and restored.get_player_standing().get_resolved_needs() == 1 + and is_equal_approx( + restored.get_player_standing().get_standing(), quest.get_standing_reward() + ) + ), + "Completed quest standing should restore deterministically through the schema", + ) + manager.set_process(true) + _finish() + + +func _create_restored_manager(saved_json: String) -> Node: + var restored: Node = load("res://simulation/SimulationManager.gd").new() + restored.simulation_seed = 909 + restored.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), + ] + restored.home_positions = home_positions + root.add_child(restored) + restored.set_process(false) + if not restored.restore_state_from_json(saved_json): + return null + return restored + + +func _find_food_node(manager: Node) -> ResourceNode: + for node in ResourceNode.get_all(): + var state: ResourceStateRecord = ( + manager.get_resource_state(node.node_id) as ResourceStateRecord + ) + if ( + node.resource_id == SimulationIds.RESOURCE_FOOD + and state != null + and state.can_player_use_resource() + and state.can_extract() + and state.get_amount_remaining() >= 1.0 + ): + return node + return null + + +func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void: + for npc in [first, second]: + npc.set_task(SimulationIds.ACTION_PATROL) + npc.target_id = &"guard_post" + npc.start_working() + first.position = position + second.position = position + Vector3(1.0, 0.0, 0.0) + + +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 _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void: + manager.synchronize_npc_position(npc.id, position) + var visual := view.active_npc_visuals.get(npc.id) as Node3D + if visual != null: + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + visual.global_position = position + + +func _freeze_visuals(view: Node) -> void: + for visual in view.active_npc_visuals.values(): + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Quest journal runtime surfacing passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/quest_journal_surface_test.gd.uid b/tests/quest_journal_surface_test.gd.uid new file mode 100644 index 0000000..96c68c6 --- /dev/null +++ b/tests/quest_journal_surface_test.gd.uid @@ -0,0 +1 @@ +uid://bl3m6a3k78cxe diff --git a/world/ui/quest_journal_hud.gd b/world/ui/quest_journal_hud.gd new file mode 100644 index 0000000..314e026 --- /dev/null +++ b/world/ui/quest_journal_hud.gd @@ -0,0 +1,143 @@ +class_name QuestJournalHud +extends Control + +const ENTER_OFFSET := 8.0 +const THANKS_SECONDS := 3.4 + +@export var simulation_manager: Node + +@onready var accent: ColorRect = $Accent +@onready var copy: VBoxContainer = $Copy +@onready var standing_label: Label = $Copy/Standing +@onready var quest_label: Label = $Copy/Quest +@onready var quest_kicker: Label = $Copy/QuestKicker +@onready var thanks_label: Label = $Copy/Thanks + +var resting_position: Vector2 +var feedback_version := 0 +var feedback_tween: Tween +var journal_tween: Tween + + +func _ready() -> void: + resting_position = position + visible = false + modulate.a = 0.0 + if simulation_manager == null: + push_error("QuestJournalHud: simulation_manager missing") + return + if simulation_manager.has_signal("player_quest_opened"): + simulation_manager.player_quest_opened.connect(_on_quest_opened) + if simulation_manager.has_signal("player_quest_completed"): + simulation_manager.player_quest_completed.connect(_on_quest_completed) + if simulation_manager.has_signal("player_quest_expired"): + simulation_manager.player_quest_expired.connect(_on_quest_expired) + if simulation_manager.has_signal("player_standing_changed"): + simulation_manager.player_standing_changed.connect(_on_standing_changed) + if simulation_manager.has_signal("opportunity_resolved"): + simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) + if simulation_manager.has_signal("state_restored"): + simulation_manager.state_restored.connect(_on_state_restored) + _refresh() + + +func _on_quest_opened(_quest: PlayerQuestRecord) -> void: + _refresh() + + +func _on_quest_expired(_quest: PlayerQuestRecord) -> void: + _refresh() + + +func _on_standing_changed(_standing: PlayerStandingRecord) -> void: + _refresh() + + +func _on_opportunity_resolved( + _opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord +) -> void: + _refresh() + + +func _on_state_restored() -> void: + _refresh() + + +func _refresh() -> void: + if simulation_manager == null: + return + var standing: PlayerStandingRecord = simulation_manager.get_player_standing() + if standing == null: + return + var standing_text := ( + "Standing · %s · %.0f/100" % [standing.get_tier_name(), standing.get_standing()] + ) + standing_label.text = standing_text + var quest: PlayerQuestRecord = simulation_manager.get_active_player_quest() + if quest == null: + quest_kicker.text = "" + quest_label.text = "" + else: + var requester_name := _get_npc_name(quest.get_requester_npc_id()) + quest_kicker.text = "ACTIVE QUEST" + quest_label.text = ( + "%s · bring %.0f %s to the %s" + % [ + requester_name, + quest.get_target_amount(), + String(quest.get_resource_id()).replace("_", " "), + String(quest.get_target_id()).replace("_", " "), + ] + ) + var has_content := not quest_kicker.text.is_empty() or not standing_text.is_empty() + if not has_content: + return + if not visible: + visible = true + modulate.a = 0.0 + position = resting_position + Vector2(0.0, ENTER_OFFSET) + if journal_tween != null and journal_tween.is_valid(): + journal_tween.kill() + journal_tween = create_tween().set_parallel(true) + journal_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + journal_tween.tween_property(self, "position", resting_position, 0.3) + journal_tween.tween_property(self, "modulate:a", 1.0, 0.22) + + +func _on_quest_completed(quest: PlayerQuestRecord) -> void: + _refresh() + var requester_name := _get_npc_name(quest.get_requester_npc_id()) + _show_thanks( + "Gratitude", + ( + "%s thanks you for your help. +%.0f Standing" + % [requester_name, quest.get_standing_reward()] + ) + ) + + +func _show_thanks(heading: String, message: String) -> void: + feedback_version += 1 + var version := feedback_version + if feedback_tween != null and feedback_tween.is_valid(): + feedback_tween.kill() + thanks_label.text = "%s\n%s" % [heading, message] + thanks_label.visible = true + thanks_label.modulate.a = 0.0 + feedback_tween = create_tween().set_parallel(true) + feedback_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + feedback_tween.tween_property(thanks_label, "modulate:a", 1.0, 0.3) + feedback_tween.chain().tween_interval(THANKS_SECONDS) + feedback_tween.chain().tween_property(thanks_label, "modulate:a", 0.0, 0.5) + feedback_tween.chain().tween_callback( + func() -> void: + if version == feedback_version: + thanks_label.visible = false + ) + + +func _get_npc_name(npc_id: int) -> String: + for npc in simulation_manager.npcs: + if npc.id == npc_id: + return npc.npc_name + return "Someone" diff --git a/world/ui/quest_journal_hud.gd.uid b/world/ui/quest_journal_hud.gd.uid new file mode 100644 index 0000000..576f4a2 --- /dev/null +++ b/world/ui/quest_journal_hud.gd.uid @@ -0,0 +1 @@ +uid://bj0u6l60v2o5f diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 866b619..607161a 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -86,6 +86,7 @@ func _on_village_changed(village: SimVillage) -> void: if rates["wood_per_day"] > 0.01: wood_rate = " (−%.0f/d)" % rates["wood_per_day"] var opportunity_text := _build_active_opportunity_display() + var standing_text := _build_standing_display() village_stats_label.text = ( """ Village [%s] @@ -99,7 +100,7 @@ func _on_village_changed(village: SimVillage) -> void: Food Mod: %.2f Safety Mod: %.2f Knowledge Mod: %.2f - %s%s + %s%s%s """ % [ speed_text, @@ -113,12 +114,30 @@ func _on_village_changed(village: SimVillage) -> void: village.food_modifier, village.safety_modifier, village.knowledge_modifier, + standing_text, opportunity_text, event_text, ] ) +func _build_standing_display() -> String: + if not simulation_manager.has_method("get_player_standing"): + return "" + var standing: PlayerStandingRecord = simulation_manager.get_player_standing() + if standing == null: + return "" + return ( + "\n\nStanding\n◆ %s · %.0f/100 · %d need%s met" + % [ + standing.get_tier_name(), + standing.get_standing(), + standing.get_resolved_needs(), + "" if standing.get_resolved_needs() == 1 else "s", + ] + ) + + func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void: _refresh_npc_inspector()