Files
gamedev-the-steward/tests/jajce_runtime_integration_test.gd
T
2026-07-16 10:41:05 +02:00

573 lines
21 KiB
GDScript

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 simulation_manager: Node = main_scene.get_node("SimulationManager")
simulation_manager.set_process(false)
var saved_clock_ticks: int = simulation_manager.clock.elapsed_ticks
simulation_manager.clock.elapsed_ticks = 0
await process_frame
await process_frame
var environment: Environment = (
(main_scene.get_node("JajceWorld/WorldEnvironment") as WorldEnvironment).environment
)
_check(
is_equal_approx(environment.ambient_light_energy, 0.25),
"Day/night presentation should follow the authoritative simulation clock"
)
simulation_manager.clock.elapsed_ticks = saved_clock_ticks
await process_frame
_check(simulation_manager.npcs.size() == 6, "Baseline should create six NPCs")
var inspector_label := (
main_scene.get_node("UI/NpcInspectorPanel/MarginContainer/NpcInspectorLabel") as Label
)
_check(
(
"Relationship:" in inspector_label.text
and "familiar 50%" in inspector_label.text
and "trust 50%" in inspector_label.text
),
"Runtime inspector should render authoritative directed relationship state"
)
var contributor: SimNPC = simulation_manager.npcs[0]
var witness: SimNPC = simulation_manager.npcs[1]
var listener: SimNPC = simulation_manager.npcs[2]
for npc in simulation_manager.npcs:
npc.position = Vector3(20.0 + npc.id * 2.0, 0.0, 0.0)
contributor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
witness.hunger = 85.0
listener.hunger = 20.0
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
_check(
is_equal_approx(
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD),
1.0
),
"Runtime knowledge UI check should use a completed simulation transaction"
)
var world_view := main_scene.get_node("WorldViewManager")
var contributor_visual: Node3D = world_view.active_npc_visuals[contributor.id]
var witness_visual: Node3D = world_view.active_npc_visuals[witness.id]
var listener_visual: Node3D = world_view.active_npc_visuals[listener.id]
_check(
(
witness_visual.get_node("TrustReactionRoot").visible
and not contributor_visual.get_node("TrustReactionRoot").visible
),
"A real trust consequence should blossom only above the reacting observer"
)
var reaction_checksum: String = simulation_manager.get_state_checksum()
witness_visual.play_trust_gain_reaction()
_check(
simulation_manager.get_state_checksum() == reaction_checksum,
"Restarting the transient trust blossom must not mutate simulation state"
)
var village_ui := main_scene.get_node("UI")
village_ui.selected_npc_index = witness.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Memories 1 lasting · 0 recent" in inspector_label.text
and (
"◆ lasting · %s stocked 1 food · witnessed" % contributor.npc_name
in inspector_label.text
)
and "Relationship: %s" % contributor.npc_name in inspector_label.text
and "Because:" in inspector_label.text
and "Own history" in inspector_label.text
),
"NPC history should show the lasting witnessed fact and its relationship consequence"
)
var guard_site := (
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
)
witness.set_task(SimulationIds.ACTION_PATROL)
witness.target_id = guard_site.site_id
witness.position = guard_site.get_interaction_position()
witness.start_working()
listener.set_task(SimulationIds.ACTION_PATROL)
listener.target_id = guard_site.site_id
listener.position = witness.position + Vector3(1.0, 0.0, 0.0)
simulation_manager.notify_npc_arrived(listener.id)
village_ui.selected_npc_index = listener.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Memories 0 lasting · 1 recent" in inspector_label.text
and (
"• recent · %s stocked 1 food · heard %s" % [contributor.npc_name, witness.npc_name]
in inspector_label.text
)
),
"NPC history should identify who communicated a retained fact"
)
_check(
not listener_visual.get_node("TrustReactionRoot").visible,
"Learning a fact without a relationship consequence should not show a trust blossom"
)
var village_stats_label := (
main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label
)
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
var pantry_visual := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry/PantryPresentation")
as PantryStockVisual
)
_check(
(
pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.STOCKED
and pantry_visual.get_node("LowStock").visible
and pantry_visual.get_node("FullStock").visible
and not pantry_visual.get_node("EmptyStock").visible
),
"A stocked authoritative pantry should show its physical sacks and berry baskets"
)
contributor.hunger = 85.0
var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory(
contributor,
SimulationIds.RESOURCE_FOOD,
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
)
_check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry")
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
_check(opportunity != null, "Emptying the pantry should open one known shortage need")
if pantry_visual.transition_tween != null:
await pantry_visual.transition_tween.finished
_check(
(
pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.EMPTY
and pantry_visual.get_node("EmptyStock").visible
and not pantry_visual.get_node("LowStock").visible
and contributor_visual.get_node("OpportunityConcernRoot").visible
and not listener_visual.get_node("OpportunityConcernRoot").visible
),
"The real shortage should empty the pantry prop and concern only its interested villager"
)
_check(
(
"Village need" in village_stats_label.text
and "◆ Restock the empty pantry" in village_stats_label.text
and (
"%s is worried about the shortage" % contributor.npc_name
in village_stats_label.text
)
and "Food 0 / 1" in village_stats_label.text
),
"An open opportunity should add one compact state-derived village need"
)
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Open village need" in inspector_label.text
and "◆ Restock the empty pantry" in inspector_label.text
and "Food 0 / 1" in inspector_label.text
),
"The interested villager should expose the open pantry need in their inspector"
)
village_ui.selected_npc_index = listener.id
village_ui.call("_refresh_npc_inspector")
_check(
"Open village need" not in inspector_label.text,
"An unrelated selected villager should not inherit someone else's open need"
)
_check(
(
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD)
> 0.0
),
"Opportunity presentation should resolve from a real pantry deposit"
)
_check(
(
pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.STOCKED
and pantry_visual.get_node("RefillResponse").visible
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
"Real supply should restore visible stock, play one refill response, and clear concern"
)
_check(
"Village need" not in village_stats_label.text,
"Resolved opportunities should leave the compact village summary"
)
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Resolved village need" in inspector_label.text
and "%s restocked the pantry" % contributor.npc_name in inspector_label.text
and "Food target 1" in inspector_label.text
),
"The interested villager should retain a resolved detail naming the real actor"
)
village_ui.selected_npc_index = listener.id
village_ui.call("_refresh_npc_inspector")
_check(
"Resolved village need" not in inspector_label.text,
"Resolved opportunity detail should remain scoped to its interested villager"
)
var player_need_withdrawal: float = simulation_manager.economy.withdraw_to_inventory(
contributor,
SimulationIds.RESOURCE_FOOD,
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
)
_check(player_need_withdrawal > 0.0, "Player resolution setup should reopen a real need")
var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
_check(player_opportunity != null, "A later shortage should open a new opportunity record")
var active_need_json: String = simulation_manager.serialize_state()
var old_contributor_visual := contributor_visual
_check(
simulation_manager.restore_state_from_json(active_need_json),
"The visible shortage should survive a valid schema-v9 restore"
)
await process_frame
contributor = simulation_manager.npcs[contributor.id]
witness = simulation_manager.npcs[witness.id]
listener = simulation_manager.npcs[listener.id]
contributor_visual = world_view.active_npc_visuals[contributor.id]
player_opportunity = simulation_manager.get_active_opportunity()
_check(
(
contributor_visual != old_contributor_visual
and contributor_visual.get_node("OpportunityConcernRoot").visible
and pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.EMPTY
and pantry_visual.get_node("EmptyStock").visible
and not pantry_visual.get_node("RefillResponse").visible
),
"Restoration should rebuild concern and empty stock without replaying the refill response"
)
var player := main_scene.get_node("Player") as Node3D
var opportunity_bush := (
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
)
var opportunity_bush_state: ResourceStateRecord = simulation_manager.get_resource_state(
opportunity_bush.node_id
)
opportunity_bush_state.set_amount_remaining(1.0)
player.global_position = opportunity_bush.interaction_point.global_position
player.call("try_interact")
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and "Resolved village need" in inspector_label.text
and "◆ Player restocked the pantry" in inspector_label.text
),
"Player gathering should resolve the same need and be named from its real event"
)
var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile()
woodpile_state.withdraw(
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
)
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
var woodpile_node := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
)
contributor.position = woodpile_node.get_interaction_position()
witness.position = contributor.position
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
contributor.travel_target_position = contributor.position
contributor.start_working()
simulation_manager.simulate_tick()
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
_check(
(
runtime_helper != null
and runtime_helper.helper_npc_id == witness.id
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
),
"Runtime helper query should derive the informed trusted woodcutter"
)
var runtime_helper_reason := (
"Knows the need; trust %.2f toward %s" % [runtime_helper.trust, contributor.npc_name]
if runtime_helper != null
else "missing helper"
)
_check(
(
"◆ Supply wood for blocked work" in village_stats_label.text
and "could not finish Study" in village_stats_label.text
and "Possible helper: %s — Gather Wood" % witness.npc_name in village_stats_label.text
and runtime_helper_reason in village_stats_label.text
and "◆ Find wood for Study" in inspector_label.text
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
"A real missing-wood fact should surface through wood-specific UI without a food cue"
)
contributor.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
_check(
is_equal_approx(
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_WOOD),
1.0
),
"Runtime opportunity presentation should resolve from a real wood deposit"
)
village_ui.call("_refresh_npc_inspector")
_check(
(
"Village need" not in village_stats_label.text
and "%s supplied the woodpile" % contributor.npc_name in inspector_label.text
),
"The resolved wood need should name its exact supplier and leave the active summary"
)
_check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"Playable runtime should instance the Jajce Terrain3D world"
)
var terrain: Terrain3D = main_scene.get_node("JajceWorld/TerrainRoot/Terrain3D")
_check(
terrain.get_camera() == main_scene.get_node("CameraRig/Camera3D"),
"Runtime Terrain3D should bind to the gameplay camera"
)
var camera_rig := main_scene.get_node("CameraRig")
_check(
camera_rig.has_method("apply_presentation_preset"),
"Runtime camera should expose a presentation staging preset"
)
var presentation_focus := Vector3(-2.0, 1.2, -0.5)
camera_rig.apply_presentation_preset(presentation_focus, 128.0, 0.95)
_check(
camera_rig.global_position.distance_to(presentation_focus) > 10.0,
"Presentation preset should pull the camera back from the village focus"
)
_check(terrain.collision_mode != 0, "Runtime Terrain3D collision should be enabled")
_check(
(
main_scene.has_node("Player/Visual/Body")
and main_scene.has_node("Player/Visual/Head")
and main_scene.has_node("Player/Visual/Scarf")
),
"Player should use the same readable multi-part visual language as villagers"
)
_check(
(
not main_scene.has_node("Player/MeshInstance3D")
and not main_scene.has_node("Player/FaceMarker")
),
"Runtime should not retain the placeholder player capsule presentation"
)
_check(
not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"),
"Runtime should not keep the temporary greybox navigation ground"
)
_check(
(
not main_scene.has_node("World")
and not main_scene.has_node("ResourceNodes")
and not main_scene.has_node("ActivityMarkers")
and not main_scene.has_node("JajceWorld/LegacyActivityMarkers")
),
"Runtime should not retain duplicate flat-world objects"
)
var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes")
_check(
resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes"
)
_check(
simulation_manager.resource_states.size() == 18,
"Simulation authority should bind all eighteen Jajce resources"
)
var village_root := main_scene.get_node("JajceWorld/VillageRoot")
var path_strips := 0
for child in village_root.get_children():
if child.name.begins_with("Path_"):
path_strips += 1
_check(
path_strips >= 4, "Runtime should include authored path strips for first-read composition"
)
_check(
main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"),
"Runtime should include a readable ridge landmark banner"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillagePantry/PantrySign"),
"Pantry storage should have a readable work-site silhouette"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/ActivitySites/GuardPost/GuardPennant"),
"Guard site should have a readable work-site silhouette"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/ActivitySites/StudyDesk/OpenBook_A"),
"Study site should have readable study props"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/ActivitySites/RestBench/RestCanopy"),
"Rest site should have a readable rest silhouette"
)
var navigation_map: RID = main_scene.get_world_3d().navigation_map
var navigation_region := (
main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
)
_check(
(
navigation_region.navigation_mesh.resource_path
== "res://world/jajce/JajceNavigationMesh.tres"
),
"Runtime navigation should use the Terrain3D-derived baked mesh resource"
)
await _wait_for_navigation_map(navigation_map)
NavigationServer3D.map_force_update(navigation_map)
var fixed_origins := [Vector3(-6.0, 0.0, -4.0), Vector3(0.0, 0.0, 0.0), Vector3(6.0, 0.0, 4.0)]
var destinations: Array[Vector3] = []
_check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillagePantry"),
"Runtime should expose a typed VillagePantry StorageNode"
)
_check(
not main_scene.has_node("JajceWorld/LegacyActivityMarkers/PantryMarker"),
"Runtime should not keep the pantry as a legacy activity marker"
)
var pantry := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
)
destinations.append(pantry.get_interaction_position())
_check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
"Runtime should expose a typed VillageWoodpile StorageNode"
)
var woodpile := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
)
destinations.append(woodpile.get_interaction_position())
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
for site in activity_root.get_children():
destinations.append((site as ActivitySite).get_interaction_position())
for resource in resource_root.get_children():
destinations.append((resource as ResourceNode).interaction_point.global_position)
for origin in fixed_origins:
for destination in destinations:
var path := NavigationServer3D.map_get_path(navigation_map, origin, destination, true)
_check(
not path.is_empty(),
"Navigation path should exist from %s to %s" % [origin, destination]
)
_check_path_tracks_terrain(
terrain, path, "Runtime navigation path should track Terrain3D height"
)
_check_path_reaches_destination(
path, destination, "Runtime navigation path should reach its requested target"
)
var village := SimVillage.new()
var worker := SimNPC.new(100, "BaselineWorker", SimulationIds.PROFESSION_SCHOLAR, 5.0, 5.0)
worker.set_task(SimulationIds.ACTION_STUDY, 2.0)
worker.start_working()
var executor := ActionExecutionSystem.new()
executor.advance_npc(worker, village)
executor.advance_npc(worker, village)
_check(worker.task_complete, "A two-tick working task should complete")
var starving := SimNPC.new(101, "BaselineStarving", SimulationIds.PROFESSION_WANDERER, 5.0, 5.0)
starving.hunger = 100.0
starving.starvation_death_threshold = 1
executor.advance_npc(starving, village)
_check(starving.is_dead, "Starvation threshold should still kill an NPC")
var adapter := main_scene.get_node("ActiveWorldAdapter") as ActiveWorldAdapter
var storage_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_FOOD)
_check(
storage_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_PANTRY),
"Runtime adapter should route storage actions to village_pantry"
)
var wood_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_WOOD)
_check(
wood_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_WOODPILE),
"Runtime adapter should route wood deposit to village_woodpile"
)
var study_target := adapter.get_activity_target(SimulationIds.ACTION_STUDY)
_check(
study_target.get("target_id", "") == "study_desk",
"Runtime adapter should route study to the typed study desk"
)
if failures.is_empty():
print("[TEST] Jajce runtime passed: 6 NPCs, 18 resources, typed sites")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _wait_for_navigation_map(navigation_map: RID) -> void:
for attempt in 30:
if (
NavigationServer3D.map_get_iteration_id(navigation_map) > 0
and not NavigationServer3D.map_get_regions(navigation_map).is_empty()
):
await physics_frame
return
await physics_frame
_check(false, "Navigation map did not synchronize within 30 physics frames")
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _check_path_tracks_terrain(
terrain: Terrain3D, path: PackedVector3Array, message: String
) -> void:
if path.is_empty():
return
for point in path:
var terrain_height: float = terrain.data.get_height(point)
if is_nan(terrain_height):
failures.append("%s: missing terrain height at %s" % [message, point])
return
if absf(point.y - terrain_height) > 0.85:
failures.append(
(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
)
)
return
func _check_path_reaches_destination(
path: PackedVector3Array, destination: Vector3, message: String
) -> void:
if path.is_empty():
return
var final_point := path[path.size() - 1]
var final_xz := Vector2(final_point.x, final_point.z)
var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5:
failures.append(
"%s: final path point %s is too far from %s" % [message, final_point, destination]
)