From f82d9683de7f47e824fdcac515abf3f1153d053d Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 16 Jul 2026 00:37:59 +0200 Subject: [PATCH] feat: package pantry crisis demo --- docs/BUILD_IN_PUBLIC_PLAN.md | 17 +- docs/LEARNING_ROADMAP.md | 28 ++- docs/PROJECT_CONTEXT.md | 17 +- main.tscn | 36 ++- player/npc/NpcVisual.gd | 11 + tests/pantry_crisis_demo_test.gd | 169 +++++++++++++ tests/pantry_crisis_demo_test.gd.uid | 1 + tools/validate_pantry_crisis_demo.gd | 58 +++++ tools/validate_pantry_crisis_demo.gd.uid | 1 + world/demo/DemoController.gd | 301 ++++++++++++++++++++++- world/demo/DemoController.gd.uid | 1 + world/ui/ui.gd | 2 +- 12 files changed, 621 insertions(+), 21 deletions(-) create mode 100644 tests/pantry_crisis_demo_test.gd create mode 100644 tests/pantry_crisis_demo_test.gd.uid create mode 100644 tools/validate_pantry_crisis_demo.gd create mode 100644 tools/validate_pantry_crisis_demo.gd.uid create mode 100644 world/demo/DemoController.gd.uid diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index ca3a403..195850a 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -503,6 +503,15 @@ Jasmin—rather than an unnamed cinematic extra. The clip has an understandable beginning, decision, and consequence. It does not depend on a voiceover to prove that the simulation is real. +**Status: complete.** `F11` reloads the fixed authored start and runs one +cinematic pantry-crisis story: a brief valley establishment, the empty pantry +and worried villager, a trusted helper's real finite-resource trip, visible +carrying, deposit, and physical recovery. Four short cardless captions fade over +the world while the camera hands off from the pantry to the helper. The same +withdrawal, consumption, extraction, and deposit records drive the need and its +resolution. `tools/validate_pantry_crisis_demo.gd` verifies that the unassisted +route completes inside the 20–30 second budget. + ## Build-in-public content pillars Rotate between four types of updates. @@ -720,11 +729,9 @@ Completed: Next: -1. Package one 20–30 second, repeatable pantry-crisis sequence that communicates - shortage, purposeful gathering, visible carrying, deposit, and recovery - without debug UI. Treat any art or timing work as support for that single - readable cause-and-effect loop. Resume the second missing-wood opportunity - proof after this public-demo exit test, as sequenced in +1. Resume the second bounded opportunity proof from a real missing-wood + `task_blocked` fact. Include deterministic interested-party death/staleness + handling before extracting shared opportunity machinery, as sequenced in `LEARNING_ROADMAP.md`. Do not start with GIS data, a full city, a large asset pack, or more NPC diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 513f4a1..df3973e 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -742,14 +742,26 @@ complete: - the repeatable runtime capture now includes an empty/restocked close pair in the established elevated third-person visual language. -Milestone 7 is not complete. The immediate outcome-defined slice is now one -short, repeatable pantry-crisis sequence that reads without debug UI: shortage, -interested villager, finite-resource trip, visible carrying, deposit, and -physical recovery. Improve route timing, work feedback, and village composition -only where that sequence exposes a readability problem. Once that public-demo -loop passes, prove a second bounded unresolved-condition consumer from a real -missing-wood `task_blocked` fact, include deterministic interested-party -death/staleness handling, and only then extract shared opportunity machinery. +The outcome-defined pantry-crisis demo loop is complete: + +- `F11` reloads the authored seed and runs a measured 20–30 second cinematic + sequence with the development UI hidden; +- a real final-food withdrawal and consumption leave the physical pantry empty + and open the knowledge-gated need for its worried witness; +- a trusted acquaintance selects ordinary `gather_food`, reserves and visits a + finite berry source, visibly carries its yield home, and autonomously selects + the real pantry deposit; +- that exact deposit resolves the opportunity, restores the physical stock, + clears concern, and triggers the existing refill response; +- restrained captions and a camera handoff clarify the three beats without + replacing state-driven world cues, while a runtime validator measures the + unassisted route and a headless scenario verifies its full event chain. + +Milestone 7 is not complete. The immediate next slice is the second bounded +unresolved-condition consumer from a real missing-wood `task_blocked` fact, +including deterministic interested-party death/staleness handling. Extract +shared opportunity machinery only after that second consumer proves the common +contract. Recently completed: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index db4205f..e3045ce 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -882,11 +882,18 @@ facts now have a cardless world expression: empty/low/stocked pantry props, one interested-villager concern cue, and a brief refill response. Save/load rebuilds the stable state without replaying the response. -Milestone 7 remains in progress. The immediate next slice is an outcome-defined -20–30 second pantry-crisis demo loop—shortage, finite-resource travel, visible -carrying, deposit, and physical recovery—without debug UI. The second -missing-wood consumer and deterministic interested-party death/staleness rules -follow that visual exit test, before common opportunity machinery is extracted. +The outcome-defined pantry-crisis demo loop is complete. `F11` reloads a fixed +authored start and packages a measured 20–30 second cinematic story with brief +captions: real withdrawal and consumption expose the shortage, a worried +informed villager remains at the empty pantry, and a trusted helper selects a +finite berry source, visibly carries its yield, deposits it, and causes the +physical recovery. A headless lifecycle scenario and an unassisted runtime +timing validator cover the complete path. + +Milestone 7 remains in progress. The immediate next slice is the second +missing-wood consumer from a real `task_blocked` fact, including deterministic +interested-party death/staleness handling before common opportunity machinery +is extracted. The remaining simulation-garden target still aims for: diff --git a/main.tscn b/main.tscn index 19f5bf2..87980d9 100644 --- a/main.tscn +++ b/main.tscn @@ -126,7 +126,41 @@ offset_right = 940.0 offset_bottom = 66.0 script = ExtResource("12_timedial") -[node name="DemoController" type="Node" parent="." unique_id=644713371 node_paths=PackedStringArray("ui", "active_npcs_parent")] +[node name="CrisisCaptionLayer" type="CanvasLayer" parent="."] +layer = 4 + +[node name="CrisisCaption" type="Label" parent="CrisisCaptionLayer"] +visible = false +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -560.0 +offset_top = -112.0 +offset_right = 560.0 +offset_bottom = -54.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.98, 0.92, 0.78, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.055, 0.035, 0.92) +theme_override_colors/font_shadow_color = Color(0.02, 0.015, 0.01, 0.7) +theme_override_constants/outline_size = 9 +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 3 +theme_override_font_sizes/font_size = 26 +text = "The pantry is empty." +horizontal_alignment = 1 +vertical_alignment = 1 + +[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") active_npcs_parent = NodePath("../ActiveNPCs") +simulation_manager = NodePath("../SimulationManager") +world_view_manager = NodePath("../WorldViewManager") +camera_rig = NodePath("../CameraRig") +player = NodePath("../Player") +pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry") +crisis_caption = NodePath("../CrisisCaptionLayer/CrisisCaption") diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 26b9d0f..3f13af1 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -327,6 +327,17 @@ func set_target_position(pos: Vector3) -> void: debug_log("New target: %s Path points: %s" % [pos, current_path.size()]) +func stop_travel() -> void: + path_request_id += 1 + current_path = PackedVector3Array() + path_index = 0 + current_target = Vector3.INF + path_pending = false + has_reported_arrival = true + stuck_time = 0.0 + velocity = Vector3.ZERO + + func _physics_process(delta: float) -> void: if is_dead_visual: velocity = Vector3.ZERO diff --git a/tests/pantry_crisis_demo_test.gd b/tests/pantry_crisis_demo_test.gd new file mode 100644 index 0000000..78fd846 --- /dev/null +++ b/tests/pantry_crisis_demo_test.gd @@ -0,0 +1,169 @@ +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 := main_scene.get_node("SimulationManager") + var world_view := main_scene.get_node("WorldViewManager") + var demo := main_scene.get_node("DemoController") + var caption := main_scene.get_node("CrisisCaptionLayer/CrisisCaption") as Label + manager.set_process(false) + + var staged: Dictionary = demo.stage_pantry_crisis() + _check(not staged.is_empty(), "The repeatable pantry crisis should stage from a fresh scene") + if staged.is_empty(): + _finish() + return + var helper: SimNPC = staged["helper"] + var interested: SimNPC = staged["interested"] + var shortage_actor: SimNPC = staged["shortage_actor"] + var opportunity: OpportunityStateRecord = staged["opportunity"] + var pantry: StorageStateRecord = manager.get_pantry() + var helper_visual: Node3D = world_view.active_npc_visuals.get(helper.id) + var interested_visual: Node3D = world_view.active_npc_visuals.get(interested.id) + + _check( + is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0), + "The demo shortage should empty the authoritative pantry" + ) + _check( + is_equal_approx(shortage_actor.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0), + "The final pantry unit should be consumed through the real economy before the food run" + ) + _check( + ( + opportunity.get_interested_npc_id() == interested.id + and interested_visual.get_node("OpportunityConcernRoot").visible + and interested_visual.current_target == Vector3.INF + ), + "The informed hungry villager should own and visibly express the open need" + ) + _check( + not caption.visible, + "The cinematic caption should remain hidden until the full sequence starts" + ) + + _check(demo.begin_pantry_restock(), "A trusted helper should begin the real food run") + var decision: ActionSelectionResult = manager.get_latest_decision(helper.id) + _check( + ( + decision != null + and decision.action_id == SimulationIds.ACTION_GATHER_FOOD + and interested.npc_name in decision.reason + ), + "The helper's selected action should be caused by the starving trusted villager" + ) + _check( + ( + helper.task_state == SimNPC.TASK_STATE_TRAVELING + and helper.has_travel_target + and not helper.target_id.is_empty() + ), + "The helper should reserve and travel to a finite food ResourceNode" + ) + var resource_state: ResourceStateRecord = manager.get_resource_state(helper.target_id) + _check( + ( + resource_state != null + and resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD + and resource_state.get_reserved_by() == helper.id + ), + "The demo trip should use the normal finite-resource reservation contract" + ) + + helper_visual.global_position = helper.travel_target_position + manager.synchronize_npc_position(helper.id, helper_visual.global_position) + manager.notify_npc_arrived(helper.id) + for _tick in int(ceil(helper.task_duration)): + manager.simulate_tick() + _check( + ( + helper.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0 + and helper_visual.get_node("CarriedFood").visible + ), + "Completing the real gather action should create visible carried food" + ) + + manager.simulate_tick() + _check( + ( + helper.current_task == SimulationIds.ACTION_DEPOSIT_FOOD + and helper.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY + and helper.has_travel_target + ), + "The helper should autonomously choose the real pantry deposit after gathering" + ) + helper_visual.global_position = helper.travel_target_position + manager.synchronize_npc_position(helper.id, helper_visual.global_position) + manager.notify_npc_arrived(helper.id) + manager.simulate_tick() + + var pantry_visual := ( + main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry/PantryPresentation") + as PantryStockVisual + ) + var resolved: OpportunityStateRecord = manager.opportunity_system.get_latest() + _check( + ( + manager.get_active_opportunity() == null + and resolved != null + and resolved.get_status() == SimulationIds.OPPORTUNITY_STATUS_RESOLVED + and resolved.get_resolution_event_id() > resolved.get_trigger_event_id() + ), + "The exact later deposit event should resolve the same opportunity record" + ) + _check( + ( + pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0 + and pantry_visual.get_node("RefillResponse").visible + and not interested_visual.get_node("OpportunityConcernRoot").visible + ), + "The deposit should physically restock the pantry and clear the concern cue" + ) + _check( + ( + _has_event(manager.economic_events, SimulationIds.EVENT_RESOURCE_EXTRACTED, helper.id) + and _has_event( + manager.economic_events, SimulationIds.EVENT_STORAGE_DEPOSITED, helper.id + ) + ), + "The sequence should be traceable through real extraction and deposit events" + ) + + _finish() + + +func _has_event(events: Array[EconomicEventRecord], event_type: StringName, actor_id: int) -> bool: + for event in events: + if ( + StringName(event.data["event_type"]) == event_type + and int(event.data["actor_id"]) == actor_id + ): + return true + return false + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Pantry crisis demo scenario passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/pantry_crisis_demo_test.gd.uid b/tests/pantry_crisis_demo_test.gd.uid new file mode 100644 index 0000000..b64dbb2 --- /dev/null +++ b/tests/pantry_crisis_demo_test.gd.uid @@ -0,0 +1 @@ +uid://drf7vq0pk51ys diff --git a/tools/validate_pantry_crisis_demo.gd b/tools/validate_pantry_crisis_demo.gd new file mode 100644 index 0000000..e0956f0 --- /dev/null +++ b/tools/validate_pantry_crisis_demo.gd @@ -0,0 +1,58 @@ +extends SceneTree + +const MINIMUM_DURATION_SECONDS := 20.0 +const MAXIMUM_DURATION_SECONDS := 30.0 +const HARD_TIMEOUT_SECONDS := 36.0 + +var completed := false +var failed_reason := "" + + +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 demo := main_scene.get_node("DemoController") + demo.pantry_crisis_completed.connect(_on_demo_completed) + demo.pantry_crisis_failed.connect(_on_demo_failed) + var started_at := Time.get_ticks_msec() + demo.start_pantry_crisis_demo() + + while not completed and failed_reason.is_empty(): + var elapsed := (Time.get_ticks_msec() - started_at) / 1000.0 + if elapsed > HARD_TIMEOUT_SECONDS: + failed_reason = "Timed out after %.1f seconds" % elapsed + break + await process_frame + + var duration := (Time.get_ticks_msec() - started_at) / 1000.0 + if not failed_reason.is_empty(): + push_error("[TOOL] Pantry crisis demo failed: %s" % failed_reason) + quit(1) + return + if duration < MINIMUM_DURATION_SECONDS or duration > MAXIMUM_DURATION_SECONDS: + push_error( + ( + "[TOOL] Pantry crisis demo took %.1f seconds; expected %.0f–%.0f" + % [duration, MINIMUM_DURATION_SECONDS, MAXIMUM_DURATION_SECONDS] + ) + ) + quit(1) + return + print("[TOOL] Pantry crisis demo completed in %.1f seconds" % duration) + quit(0) + + +func _on_demo_completed(_helper_id: int, _resolution_event_id: int) -> void: + completed = true + + +func _on_demo_failed(reason: String) -> void: + failed_reason = reason diff --git a/tools/validate_pantry_crisis_demo.gd.uid b/tools/validate_pantry_crisis_demo.gd.uid new file mode 100644 index 0000000..de4a01a --- /dev/null +++ b/tools/validate_pantry_crisis_demo.gd.uid @@ -0,0 +1 @@ +uid://be73skpg0q6ub diff --git a/world/demo/DemoController.gd b/world/demo/DemoController.gd index 120bade..5dc7865 100644 --- a/world/demo/DemoController.gd +++ b/world/demo/DemoController.gd @@ -1,14 +1,42 @@ extends Node +signal pantry_crisis_staged(helper_id: int, interested_id: int) +signal pantry_crisis_completed(helper_id: int, resolution_event_id: int) +signal pantry_crisis_failed(reason: String) + +const FEATURED_HELPER_INDEX := 0 +const INTERESTED_VILLAGER_INDEX := 1 +const SHORTAGE_ACTOR_INDEX := 2 +const VALLEY_ESTABLISH_SECONDS := 2.4 +const ESTABLISH_SECONDS := 2.4 +const RECOVERY_SECONDS := 5.7 +const DEMO_TIMEOUT_SECONDS := 32.0 + +static var start_pantry_crisis_after_reload := false + @export var ui: CanvasLayer @export var active_npcs_parent: Node +@export var simulation_manager: Node +@export var world_view_manager: Node +@export var camera_rig: Node3D +@export var player: CharacterBody3D +@export var pantry_storage: StorageNode +@export var crisis_caption: Label @export var debug_overlay_visible := true var _refresh_accumulator := 0.0 +var _caption_tween: Tween +var _demo_running := false +var _featured_helper_id := -1 +var _interested_villager_id := -1 func _ready() -> void: _apply_debug_overlay_visibility() + _hide_caption() + if start_pantry_crisis_after_reload: + start_pantry_crisis_after_reload = false + call_deferred("start_pantry_crisis_demo") func _process(delta: float) -> void: @@ -25,13 +53,20 @@ func _unhandled_key_input(event: InputEvent) -> void: if event.keycode == KEY_F10: toggle_debug_overlay() get_viewport().set_input_as_handled() + elif event.keycode == KEY_F11: + request_pantry_crisis_demo() + get_viewport().set_input_as_handled() elif event.keycode == KEY_F12: reset_demo_scene() get_viewport().set_input_as_handled() func toggle_debug_overlay() -> void: - debug_overlay_visible = not debug_overlay_visible + set_debug_overlay_visible(not debug_overlay_visible) + + +func set_debug_overlay_visible(is_visible: bool) -> void: + debug_overlay_visible = is_visible _apply_debug_overlay_visibility() print( ( @@ -41,11 +76,275 @@ func toggle_debug_overlay() -> void: ) +func request_pantry_crisis_demo() -> void: + start_pantry_crisis_after_reload = true + print("[DemoController] Reloading the repeatable pantry-crisis demo") + get_tree().reload_current_scene() + + func reset_demo_scene() -> void: + start_pantry_crisis_after_reload = false print("[DemoController] Resetting simulation garden demo") get_tree().reload_current_scene() +func start_pantry_crisis_demo() -> void: + if _demo_running: + return + _demo_running = true + set_debug_overlay_visible(false) + if player != null: + player.set_physics_process(false) + player.velocity = Vector3.ZERO + _focus_camera_on_village() + _show_caption("Morning settles over the village.") + await get_tree().create_timer(VALLEY_ESTABLISH_SECONDS).timeout + if not _demo_running or not is_inside_tree(): + return + + var staged := stage_pantry_crisis() + if staged.is_empty(): + _fail_pantry_crisis("Could not stage the pantry shortage") + return + var helper: SimNPC = staged["helper"] + var interested: SimNPC = staged["interested"] + _show_caption("The pantry is empty. %s is worried." % interested.npc_name) + pantry_crisis_staged.emit(helper.id, interested.id) + await get_tree().create_timer(ESTABLISH_SECONDS).timeout + if not _demo_running or not is_inside_tree(): + return + + if not begin_pantry_restock(): + _fail_pantry_crisis("No villager could begin the food run") + return + _show_caption("%s goes to help." % helper.npc_name) + _focus_camera_on_npc(helper.id) + + var elapsed := 0.0 + var carrying_was_shown := false + while elapsed < DEMO_TIMEOUT_SECONDS: + if not is_inside_tree(): + return + var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + if opportunity == null: + var resolved: OpportunityStateRecord = ( + simulation_manager.opportunity_system.get_latest() + ) + var resolution_event_id := -1 + if resolved != null: + resolution_event_id = resolved.get_resolution_event_id() + _show_caption("%s restocks the pantry." % helper.npc_name) + await get_tree().create_timer(RECOVERY_SECONDS).timeout + _demo_running = false + pantry_crisis_completed.emit(helper.id, resolution_event_id) + return + if ( + not carrying_was_shown + and helper.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0 + ): + carrying_was_shown = true + _show_caption("Fresh berries, carried all the way home.") + await get_tree().process_frame + elapsed += get_process_delta_time() + + _fail_pantry_crisis("The food route did not finish in time") + + +func stage_pantry_crisis() -> Dictionary: + if not _has_demo_dependencies() or simulation_manager.npcs.size() < 3: + return {} + var helper: SimNPC = simulation_manager.npcs[FEATURED_HELPER_INDEX] + var interested: SimNPC = simulation_manager.npcs[INTERESTED_VILLAGER_INDEX] + var shortage_actor: SimNPC = simulation_manager.npcs[SHORTAGE_ACTOR_INDEX] + var pantry_position := pantry_storage.get_interaction_position() + + for npc in simulation_manager.npcs: + _park_npc_for_demo(npc) + _place_npc_for_demo(helper, pantry_position + Vector3(-1.25, 0.0, 0.15)) + _place_npc_for_demo(interested, pantry_position + Vector3(0.0, 0.0, 0.2)) + _place_npc_for_demo(shortage_actor, pantry_position + Vector3(1.25, 0.0, 0.15)) + + var relationship: RelationshipStateRecord = ( + simulation_manager.relationship_system.get_relationship(helper.id, interested.id) + ) + if relationship == null: + return {} + if relationship.get_trust() < 0.6: + helper.hunger = 85.0 + interested.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + if ( + simulation_manager.economy.deposit_inventory(interested, SimulationIds.RESOURCE_FOOD) + <= 0.0 + ): + return {} + if relationship.get_trust() < 0.6: + return {} + + var pantry: StorageStateRecord = simulation_manager.get_pantry() + if pantry == null: + return {} + var current_food := pantry.get_amount(SimulationIds.RESOURCE_FOOD) + if current_food > 1.0: + pantry.withdraw(SimulationIds.RESOURCE_FOOD, current_food - 1.0) + elif current_food < 1.0: + pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0 - current_food) + simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + _set_demo_needs(helper, 45.0, false) + _set_demo_needs(interested, 92.0, true) + _set_demo_needs(shortage_actor, 45.0, false) + var withdrawn: float = simulation_manager.economy.withdraw_to_inventory( + shortage_actor, SimulationIds.RESOURCE_FOOD, 1.0 + ) + var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + if ( + not is_equal_approx(withdrawn, 1.0) + or opportunity == null + or opportunity.get_interested_npc_id() != interested.id + ): + return {} + if not simulation_manager.economy.consume_npc_food(shortage_actor): + return {} + + _featured_helper_id = helper.id + _interested_villager_id = interested.id + _focus_camera_on_pantry(interested.id) + return { + "helper": helper, + "interested": interested, + "shortage_actor": shortage_actor, + "opportunity": opportunity, + } + + +func begin_pantry_restock() -> bool: + var helper := _get_npc(_featured_helper_id) + var interested := _get_npc(_interested_villager_id) + if helper == null or interested == null or simulation_manager.get_active_opportunity() == null: + return false + var selection: ActionSelectionResult = simulation_manager.action_selector.select_action( + helper, + simulation_manager.village, + simulation_manager.clock.time_of_day(), + simulation_manager.npcs + ) + if ( + selection == null + or selection.action_id != SimulationIds.ACTION_GATHER_FOOD + or interested.npc_name not in selection.reason + ): + return false + var previous_task := helper.current_task + simulation_manager.latest_decisions[helper.id] = selection + simulation_manager.npc_decision_recorded.emit(helper, selection) + helper.set_task(selection.action_id, selection.duration_override) + simulation_manager.npc_task_changed.emit(helper, previous_task, selection.action_id) + simulation_manager.npc_target_requested.emit(helper) + return ( + helper.task_state == SimNPC.TASK_STATE_TRAVELING + and not helper.target_id.is_empty() + and helper.has_travel_target + ) + + +func is_pantry_crisis_running() -> bool: + return _demo_running + + +func _has_demo_dependencies() -> bool: + return ( + simulation_manager != null + and world_view_manager != null + and camera_rig != null + and pantry_storage != null + and crisis_caption != null + and "npcs" in simulation_manager + and "active_npc_visuals" in world_view_manager + ) + + +func _park_npc_for_demo(npc: SimNPC) -> void: + var previous_task := npc.current_task + simulation_manager.release_npc_reservation(npc.id) + npc.set_task(SimulationIds.ACTION_WANDER, 1000.0) + npc.start_working() + simulation_manager.npc_task_changed.emit(npc, previous_task, npc.current_task) + + +func _place_npc_for_demo(npc: SimNPC, position: Vector3) -> void: + var visual := world_view_manager.active_npc_visuals.get(npc.id) as Node3D + if visual == null: + return + if visual.has_method("stop_travel"): + visual.stop_travel() + visual.global_position = position + simulation_manager.synchronize_npc_position(npc.id, position) + + +func _set_demo_needs(npc: SimNPC, hunger: float, starving: bool) -> void: + npc.hunger = hunger + npc.energy = 90.0 + npc.is_starving = starving + npc.starvation_ticks = 0 + + +func _focus_camera_on_pantry(interested_id: int) -> void: + var interested_visual := world_view_manager.active_npc_visuals.get(interested_id) as Node3D + if interested_visual == null: + return + camera_rig.target = interested_visual + camera_rig.pitch_degrees = -44.0 + camera_rig.apply_presentation_preset( + pantry_storage.global_position + Vector3(0.0, 0.9, 0.5), 128.0, 0.28 + ) + + +func _focus_camera_on_village() -> void: + camera_rig.target = player + camera_rig.pitch_degrees = -42.0 + camera_rig.apply_presentation_preset(Vector3(-1.5, 1.2, -4.0), 128.0, 0.7) + + +func _focus_camera_on_npc(npc_id: int) -> void: + var visual := world_view_manager.active_npc_visuals.get(npc_id) as Node3D + if visual != null: + camera_rig.target = visual + + +func _show_caption(message: String) -> void: + if crisis_caption == null: + return + if _caption_tween != null: + _caption_tween.kill() + crisis_caption.text = message + crisis_caption.visible = true + crisis_caption.modulate.a = 0.0 + _caption_tween = create_tween() + _caption_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) + _caption_tween.tween_property(crisis_caption, "modulate:a", 1.0, 0.28) + + +func _hide_caption() -> void: + if crisis_caption == null: + return + crisis_caption.visible = false + crisis_caption.modulate.a = 1.0 + + +func _fail_pantry_crisis(reason: String) -> void: + _demo_running = false + _show_caption("The food route was interrupted.") + push_error("DemoController: %s" % reason) + pantry_crisis_failed.emit(reason) + + +func _get_npc(npc_id: int) -> SimNPC: + for npc in simulation_manager.npcs: + if npc.id == npc_id: + return npc + return null + + func _apply_debug_overlay_visibility() -> void: if ui != null and ui.has_method("set_debug_overlay_visible"): ui.set_debug_overlay_visible(debug_overlay_visible) diff --git a/world/demo/DemoController.gd.uid b/world/demo/DemoController.gd.uid new file mode 100644 index 0000000..13d826c --- /dev/null +++ b/world/demo/DemoController.gd.uid @@ -0,0 +1 @@ +uid://bivrsk5ukgnoo diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 55ad51d..8ed9038 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -226,7 +226,7 @@ func _refresh_npc_inspector() -> void: + "%s" + "Why\n%s%s\n\n" + "Own history\n%s\n\n" - + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" + + "[Tab] Next villager [F10] Cinematic/debug [F11] Pantry story [F12] Reset" ) % [ npc.npc_name,