627 lines
23 KiB
GDScript
627 lines
23 KiB
GDScript
extends SceneTree
|
|
|
|
const JajceWatercourse := preload("res://world/jajce/JajceWatercourse.gd")
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
var camera := Camera3D.new()
|
|
camera.current = true
|
|
root.add_child(camera)
|
|
var configured_assets: Terrain3DAssets = load("res://terrain/jajce/assets.tres")
|
|
var configured_texture_count := configured_assets.get_texture_count()
|
|
|
|
var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
|
root.add_child(world)
|
|
await process_frame
|
|
for _frame in 10:
|
|
await physics_frame
|
|
|
|
var terrain = world.get_node("TerrainRoot/Terrain3D")
|
|
_check(terrain is Terrain3D, "JajceWorld should own a Terrain3D node")
|
|
_check(
|
|
terrain.data_directory == "res://terrain/jajce/data",
|
|
"Terrain3D should use dedicated Jajce data"
|
|
)
|
|
_check(terrain.collision_mode != 0, "Terrain3D collision should be enabled for gameplay")
|
|
_check(
|
|
not world.has_node("NavigationRegion3D/GreyboxGround"),
|
|
"JajceWorld should not keep the temporary greybox navigation ground"
|
|
)
|
|
_check(
|
|
configured_texture_count == 6,
|
|
(
|
|
"Jajce terrain should define the bounded six-material palette (found %s)"
|
|
% configured_texture_count
|
|
)
|
|
)
|
|
var texture_contract_valid := true
|
|
for texture_index in configured_texture_count:
|
|
var texture_asset: Terrain3DTextureAsset = configured_assets.get_texture(texture_index)
|
|
texture_contract_valid = (
|
|
texture_contract_valid
|
|
and texture_asset.albedo_texture != null
|
|
and texture_asset.normal_texture != null
|
|
and texture_asset.albedo_texture.get_image().has_mipmaps()
|
|
and texture_asset.normal_texture.get_image().has_mipmaps()
|
|
)
|
|
_check(
|
|
texture_contract_valid,
|
|
"All Terrain3D texture assets should provide matching mipmapped maps"
|
|
)
|
|
_check(
|
|
absf(terrain.data.get_height(Vector3.ZERO)) < 0.1,
|
|
"Central gameplay area should remain level for current navigation"
|
|
)
|
|
_check(
|
|
terrain.data.get_height(Vector3(-72, 0, 18)) > 10.0,
|
|
"Authored terrain should raise the western fortress ridge"
|
|
)
|
|
_check(world.has_node("WaterRoot"), "JajceWorld should expose WaterRoot")
|
|
_check(world.has_node("VillageRoot"), "JajceWorld should expose VillageRoot")
|
|
_check(world.has_node("FoliageRoot"), "JajceWorld should expose FoliageRoot")
|
|
_check(world.has_node("WildlifeRoot"), "JajceWorld should expose ambient WildlifeRoot")
|
|
_check(
|
|
world.get_node("FoliageRoot").get_child_count() >= 10,
|
|
"JajceWorld should include restrained authored foliage clusters"
|
|
)
|
|
var conifer_count := 0
|
|
for foliage in world.get_node("FoliageRoot").get_children():
|
|
if foliage.name.begins_with("Conifer_"):
|
|
conifer_count += 1
|
|
_check(conifer_count >= 6, "Citadel and river edges should include conifer silhouettes")
|
|
_check(
|
|
world.get_node("WildlifeRoot").get_child_count() >= 3,
|
|
"Village center should include a few oversized ambient butterflies"
|
|
)
|
|
var grass_field := world.get_node("TerrainRoot/Terrain3D/CozyGrassField")
|
|
var grass_material := grass_field.get("mesh_material_override") as ShaderMaterial
|
|
var grass_process := grass_field.get("process_material") as ShaderMaterial
|
|
_check(
|
|
int(grass_field.get("particle_count")) in range(9000, 15001),
|
|
"Camera-local Terrain3D grass should stay inside its bounded particle budget"
|
|
)
|
|
_check(
|
|
(
|
|
grass_material != null
|
|
and grass_material.shader == load("res://world/jajce/materials/cozy_grass.gdshader")
|
|
),
|
|
"Terrain grass should use the cozy wind-and-interaction shader"
|
|
)
|
|
var grass_max_scale := Vector3.ZERO
|
|
var grass_clod_boost := INF
|
|
if grass_process != null:
|
|
grass_max_scale = grass_process.get_shader_parameter("max_scale") as Vector3
|
|
grass_clod_boost = float(grass_process.get_shader_parameter("clod_scale_boost"))
|
|
_check(
|
|
grass_process != null and grass_max_scale.y <= 0.5 and grass_clod_boost <= 0.2,
|
|
"Grass placement should stay short enough to preserve villagers and paths"
|
|
)
|
|
var grass_controller := world.get_node("TerrainRoot/GrassInteractionController")
|
|
_check(
|
|
grass_controller.get("grass_material") == grass_material,
|
|
"Grass interaction should update the same material rendered by Terrain3D particles"
|
|
)
|
|
_check(
|
|
(
|
|
not world.has_node("WindStrokes_Valley")
|
|
and not world.has_node("WindStrokes_Ridge")
|
|
and not world.has_node("WindSpecks_Valley")
|
|
and not world.has_node("WindSpecks_Ridge")
|
|
),
|
|
"Legacy overlapping valley and ridge wind fields should remain removed"
|
|
)
|
|
var wind_gust_field := world.get_node("AtmosphereRoot/WindGustField") as WindGustField
|
|
_check(wind_gust_field != null, "Jajce should expose one bounded wind-gust controller")
|
|
if wind_gust_field != null:
|
|
_check(
|
|
(
|
|
wind_gust_field.interval_min >= 5.0
|
|
and wind_gust_field.interval_max <= 12.0
|
|
and wind_gust_field.duration_max <= 2.5
|
|
and wind_gust_field.stroke_count_max <= 3
|
|
),
|
|
"Calligraphic wind should remain sporadic, brief, and sparse"
|
|
)
|
|
_check(
|
|
wind_gust_field.trigger_gust_at(Vector3.ZERO, true, 0.5),
|
|
"Wind controller should support a deterministic staged gust"
|
|
)
|
|
var active_stroke_count := wind_gust_field.get_active_stroke_count()
|
|
_check(
|
|
active_stroke_count in [2, 3],
|
|
"One gust should contain only two or three related brush strokes"
|
|
)
|
|
var gust_burst := wind_gust_field.get_node_or_null("WindGustBurst") as Node3D
|
|
var gust_geometry_valid := gust_burst != null
|
|
if gust_burst != null:
|
|
for stroke in gust_burst.get_children():
|
|
var mesh_instance := stroke as MeshInstance3D
|
|
if mesh_instance == null:
|
|
gust_geometry_valid = false
|
|
continue
|
|
var material := mesh_instance.material_override as ShaderMaterial
|
|
var tint: Color = (
|
|
material.get_shader_parameter("tint") if material != null else Color.WHITE
|
|
)
|
|
var progress := (
|
|
float(material.get_shader_parameter("progress")) if material != null else 0.0
|
|
)
|
|
gust_geometry_valid = (
|
|
gust_geometry_valid
|
|
and mesh_instance.mesh is ArrayMesh
|
|
and material != null
|
|
and material.shader == load("res://world/jajce/materials/wind_gust.gdshader")
|
|
and tint.a >= 0.18
|
|
and tint.a <= 0.3
|
|
and progress >= 0.4
|
|
and progress <= 0.6
|
|
and (mesh_instance.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
|
|
)
|
|
_check(
|
|
gust_geometry_valid,
|
|
"Gust strokes should use soft tapered meshes without bright alpha or shadows"
|
|
)
|
|
_check(
|
|
(
|
|
not wind_gust_field.trigger_gust_at(Vector3.ZERO)
|
|
and wind_gust_field.get_active_stroke_count() == active_stroke_count
|
|
),
|
|
"Repeated gust triggers should not stack visual fields"
|
|
)
|
|
var tree_phases: Array[float] = []
|
|
var tree_wind_is_bounded := true
|
|
for tree in world.get_node("FoliageRoot").get_children():
|
|
if not tree.has_node("Canopy"):
|
|
continue
|
|
var canopy := tree.get_node("Canopy") as MeshInstance3D
|
|
var material := canopy.mesh.material as ShaderMaterial
|
|
tree_wind_is_bounded = (
|
|
tree_wind_is_bounded
|
|
and material != null
|
|
and material.shader == load("res://world/jajce/materials/wind.gdshader")
|
|
and float(material.get_shader_parameter("wind_strength")) <= 0.08
|
|
and float(material.get_shader_parameter("wind_speed")) <= 0.65
|
|
)
|
|
tree_phases.append(float(material.get_shader_parameter("wind_phase")))
|
|
_check(tree_phases.size() >= 2, "Decorative trees should expose named wind-reactive canopies")
|
|
_check(
|
|
tree_phases.size() >= 2 and not is_equal_approx(tree_phases[0], tree_phases[1]),
|
|
"Tree wind should use stable per-tree phase instead of moving every canopy in lockstep"
|
|
)
|
|
_check(tree_wind_is_bounded, "Tree wind should stay within the restrained cozy-motion budget")
|
|
_check(world.has_node("FortressBlockout"), "JajceWorld should include the fortress landmark")
|
|
_check(
|
|
world.has_node("FortressBlockout/Keep/CitadelKeep"),
|
|
"Fortress silhouette should expose a distinct summit citadel"
|
|
)
|
|
_check(world.has_node("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop")
|
|
_check(
|
|
world.has_node("WaterRoot/WaterfallBody/RockFrame"),
|
|
"Waterfall should be framed by the rocky river threshold"
|
|
)
|
|
_check(
|
|
world.has_node("WaterRoot/WaterfallMist"),
|
|
"WaterRoot should include waterfall mist particles"
|
|
)
|
|
var waterfall_mist: GPUParticles3D = world.get_node("WaterRoot/WaterfallMist")
|
|
_check(
|
|
waterfall_mist.draw_pass_1 != null,
|
|
"Waterfall mist particles should have visible billboard geometry"
|
|
)
|
|
_check(
|
|
waterfall_mist.position.distance_to(Vector3(25.0, 0.65, -21.5)) < 0.01,
|
|
"Waterfall mist should stay at the waterfall base instead of obscuring the village"
|
|
)
|
|
_check(world.has_node("WaterRoot/RiverSurface"), "WaterRoot should include a river surface")
|
|
var river_ribbon := world.get_node("WaterRoot/RiverSurface/RiverRibbon") as MeshInstance3D
|
|
_check(
|
|
river_ribbon.mesh is ArrayMesh,
|
|
"River should build a lightweight ribbon from the current Terrain3D surface"
|
|
)
|
|
var upper_river_ribbon := (
|
|
world.get_node("WaterRoot/RiverSurface/UpperRiverRibbon") as MeshInstance3D
|
|
)
|
|
_check(
|
|
upper_river_ribbon.mesh is ArrayMesh,
|
|
"Waterfall shelf should expose a terrain-following upper stream"
|
|
)
|
|
_check(
|
|
world.has_node("WaterRoot/RiverSurface/PlungePool"),
|
|
"River should widen into a readable plunge pool beneath the waterfall"
|
|
)
|
|
var plunge_pool := world.get_node("WaterRoot/RiverSurface/PlungePool") as MeshInstance3D
|
|
var expected_pool_height: float = terrain.data.get_height(
|
|
Vector3(JajceWatercourse.PLUNGE_POOL_CENTER.x, 0.0, JajceWatercourse.PLUNGE_POOL_CENTER.y)
|
|
)
|
|
_check(
|
|
(
|
|
absf(
|
|
(
|
|
plunge_pool.global_position.y
|
|
- expected_pool_height
|
|
- JajceWatercourse.WATER_SURFACE_OFFSET
|
|
)
|
|
)
|
|
< 0.03
|
|
),
|
|
"Plunge-pool water should follow the carved basin height"
|
|
)
|
|
var environment: Environment = (
|
|
(world.get_node("WorldEnvironment") as WorldEnvironment).environment
|
|
)
|
|
_check(
|
|
environment.fog_enabled and environment.sky != null,
|
|
"Jajce environment should provide sky and restrained valley fog"
|
|
)
|
|
_check(
|
|
(
|
|
environment.glow_enabled
|
|
and environment.volumetric_fog_enabled
|
|
and environment.adjustment_enabled
|
|
),
|
|
"WorldEnvironment should provide bounded glow, depth haze, and color grading"
|
|
)
|
|
_check(
|
|
world.has_node("VillageRoot/Mill") and world.has_node("VillageRoot/Bridge"),
|
|
"VillageRoot should include the mill and bridge blockouts"
|
|
)
|
|
var house_count := 0
|
|
for village_child in world.get_node("VillageRoot").get_children():
|
|
if village_child.name.begins_with("House_"):
|
|
house_count += 1
|
|
_check(house_count >= 8, "Roofs should form a small residential amphitheater below the citadel")
|
|
var smoke_count := 0
|
|
var smoke_is_translucent := true
|
|
var smoke_follows_wind := true
|
|
var smoke_direction := Vector2.ZERO
|
|
for suffix in ["House_01", "House_02", "House_03"]:
|
|
if world.has_node("VillageRoot/%s/Smoke" % suffix):
|
|
var smoke: GPUParticles3D = world.get_node("VillageRoot/%s/Smoke" % suffix)
|
|
if smoke.draw_pass_1 == null:
|
|
continue
|
|
var smoke_material := (smoke.draw_pass_1 as QuadMesh).material as StandardMaterial3D
|
|
var smoke_process := smoke.process_material as ParticleProcessMaterial
|
|
if smoke_process != null and smoke_direction.is_zero_approx():
|
|
smoke_direction = (
|
|
Vector2(smoke_process.gravity.x, smoke_process.gravity.z).normalized()
|
|
)
|
|
smoke_is_translucent = (
|
|
smoke_is_translucent
|
|
and smoke_material != null
|
|
and smoke_material.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED
|
|
and smoke_material.albedo_color.a < 0.5
|
|
)
|
|
smoke_follows_wind = (
|
|
smoke_follows_wind
|
|
and smoke_process != null
|
|
and smoke_process.gravity.x > 0.0
|
|
and smoke_process.gravity.z > 0.0
|
|
)
|
|
smoke_count += 1
|
|
_check(
|
|
smoke_count >= 2,
|
|
"At least two houses should have chimney smoke particles (found %s)" % smoke_count
|
|
)
|
|
_check(smoke_is_translucent, "Chimney smoke should not render as opaque white quads")
|
|
_check(smoke_follows_wind, "Chimney smoke should lean with the prevailing canopy wind")
|
|
var gust_direction := Vector2.ZERO
|
|
if wind_gust_field != null:
|
|
gust_direction = (
|
|
Vector2(wind_gust_field.wind_direction.x, wind_gust_field.wind_direction.z).normalized()
|
|
)
|
|
_check(
|
|
gust_direction.dot(smoke_direction) >= 0.98,
|
|
"Calligraphic gusts, canopy bend, and chimney smoke should share one wind direction"
|
|
)
|
|
var lookdev: Node3D = load("res://world/jajce/JajceLookdev.tscn").instantiate()
|
|
_check(lookdev.has_node("JajceWorld"), "Lookdev should instance JajceWorld")
|
|
_check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera")
|
|
lookdev.free()
|
|
|
|
var resources := _collect_resource_nodes(world.get_node("WorldObjects"))
|
|
var ids: Array[String] = []
|
|
for resource in resources:
|
|
ids.append(String((resource as ResourceNode).node_id))
|
|
ids.sort()
|
|
_check(
|
|
(
|
|
ids
|
|
== [
|
|
"animal_camp_01",
|
|
"animal_camp_river_01",
|
|
"berry_bush_01",
|
|
"berry_bush_02",
|
|
"berry_bush_river_02",
|
|
"berry_patch_mill_01",
|
|
"berry_patch_river_01",
|
|
"berry_patch_south_01",
|
|
"farm_crop_01",
|
|
"tree_01",
|
|
"tree_02",
|
|
"tree_forest_grove_01",
|
|
"tree_forest_grove_02",
|
|
"tree_north_outskirts_01",
|
|
"tree_river_resource_01",
|
|
"tree_south_edge_01",
|
|
"wood_pile_mill_01",
|
|
"wood_pile_village_01"
|
|
]
|
|
),
|
|
"Jajce resources should preserve all stable and discoverable IDs"
|
|
)
|
|
var metadata_valid := true
|
|
var food_count := 0
|
|
var wood_count := 0
|
|
var animal_context_count := 0
|
|
var berry_context_count := 0
|
|
var village_context_count := 0
|
|
var outskirt_context_count := 0
|
|
var farming_context_count := 0
|
|
for resource in resources:
|
|
var node := resource as ResourceNode
|
|
var id := String(node.node_id)
|
|
if node.resource_id == SimulationIds.RESOURCE_FOOD:
|
|
food_count += 1
|
|
if node.resource_id == SimulationIds.RESOURCE_WOOD:
|
|
wood_count += 1
|
|
if id.begins_with("animal_camp"):
|
|
animal_context_count += 1
|
|
if id.begins_with("berry_"):
|
|
berry_context_count += 1
|
|
if id.contains("_village_"):
|
|
village_context_count += 1
|
|
if id.contains("_outskirts_") or id.contains("_edge_") or id.contains("_river_"):
|
|
outskirt_context_count += 1
|
|
if id.begins_with("farm_"):
|
|
farming_context_count += 1
|
|
metadata_valid = (
|
|
metadata_valid
|
|
and node.comfort_distance > 0.0
|
|
and node.safety_risk >= 0.0
|
|
and node.safety_risk <= 1.0
|
|
)
|
|
_check(metadata_valid, "Jajce resources should define discovery metadata")
|
|
_check(food_count >= 9, "Jajce should expose at least nine finite food resources")
|
|
_check(wood_count >= 9, "Jajce should expose at least nine finite wood resources")
|
|
_check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts")
|
|
_check(berry_context_count >= 6, "Jajce food discovery should include berry contexts")
|
|
_check(
|
|
village_context_count >= 1, "Jajce discovery should include at least one village stockpile"
|
|
)
|
|
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
|
|
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
|
|
var animal_root := world.get_node("WorldObjects/Animals")
|
|
var goat := animal_root.get_node("Dunja") as AnimalNode
|
|
_check(
|
|
animal_root.get_child_count() == 1 and goat != null,
|
|
"Jajce should contain one bounded loaded-animal presentation"
|
|
)
|
|
_check(
|
|
(
|
|
goat.animal_id == SimulationIds.ANIMAL_DUNJA
|
|
and goat.species_id == SimulationIds.SPECIES_GOAT
|
|
and goat.display_name == "Dunja"
|
|
),
|
|
"The authored goat should preserve its stable identity and readable name"
|
|
)
|
|
_check(
|
|
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
|
|
"The identity-backed goat should remain separate from finite resources"
|
|
)
|
|
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
|
|
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
|
|
_check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode")
|
|
_check(
|
|
pantry.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
"VillagePantry should preserve the stable storage ID"
|
|
)
|
|
var woodpile := world.get_node("WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
|
_check(woodpile != null, "JajceWorld should include a typed VillageWoodpile StorageNode")
|
|
_check(
|
|
woodpile.storage_id == SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
|
"VillageWoodpile should preserve the stable storage ID"
|
|
)
|
|
_check(
|
|
not world.has_node("LegacyActivityMarkers/PantryMarker"),
|
|
"Pantry should no longer be a legacy activity marker"
|
|
)
|
|
_check(
|
|
not world.has_node("LegacyActivityMarkers"),
|
|
"JajceWorld should no longer expose legacy activity markers"
|
|
)
|
|
var activity_root := world.get_node("WorldObjects/ActivitySites")
|
|
var activity_ids: Array[String] = []
|
|
for site in activity_root.get_children():
|
|
activity_ids.append(String((site as ActivitySite).site_id))
|
|
activity_ids.sort()
|
|
_check(
|
|
activity_ids == ["guard_post", "rest_bench", "study_desk"],
|
|
"Jajce activity sites should preserve stable semantic IDs"
|
|
)
|
|
|
|
var navigation_region := world.get_node("NavigationRegion3D") as NavigationRegion3D
|
|
var navigation_map: RID = NavigationServer3D.region_get_map(navigation_region.get_rid())
|
|
_check(navigation_map.is_valid(), "Jajce navigation region should be assigned to a map")
|
|
_check(
|
|
navigation_region.navigation_mesh.get_polygon_count() > 0,
|
|
"Jajce navigation mesh should contain baked polygons"
|
|
)
|
|
_check(
|
|
(
|
|
navigation_region.navigation_mesh.resource_path
|
|
== "res://world/jajce/JajceNavigationMesh.tres"
|
|
),
|
|
"Jajce navigation should use the Terrain3D-derived baked mesh resource"
|
|
)
|
|
await _wait_for_navigation_map(navigation_map)
|
|
NavigationServer3D.map_force_update(navigation_map)
|
|
|
|
var origins := [Vector3(-6, 0, -4), Vector3(0, 0, 0), Vector3(6, 0, 4)]
|
|
var destinations: Array[Vector3] = []
|
|
for resource in resources:
|
|
destinations.append((resource as ResourceNode).interaction_point.global_position)
|
|
destinations.append(goat.get_interaction_position())
|
|
destinations.append(pantry.get_interaction_position())
|
|
destinations.append(woodpile.get_interaction_position())
|
|
for site in activity_root.get_children():
|
|
destinations.append((site as ActivitySite).get_interaction_position())
|
|
|
|
for origin in origins:
|
|
for destination in destinations:
|
|
var path := NavigationServer3D.map_get_path(navigation_map, origin, destination, true)
|
|
_check(
|
|
not path.is_empty(),
|
|
"Jajce navigation path should exist from %s to %s" % [origin, destination]
|
|
)
|
|
_check_path_tracks_terrain(
|
|
terrain, path, "Jajce navigation path should track Terrain3D height"
|
|
)
|
|
_check_path_reaches_destination(
|
|
path, destination, "Jajce navigation path should reach its requested target"
|
|
)
|
|
|
|
var adapter := ActiveWorldAdapter.new()
|
|
adapter.pantry_storage = pantry
|
|
adapter.woodpile_storage = woodpile
|
|
root.add_child(adapter)
|
|
var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD)
|
|
_check(food_candidates.size() >= 9, "Adapter should expose all authored food candidates")
|
|
var candidate_metadata_valid := true
|
|
for candidate in food_candidates:
|
|
candidate_metadata_valid = (
|
|
candidate_metadata_valid
|
|
and candidate.has_all(
|
|
[
|
|
"target_id",
|
|
"position",
|
|
"resource_id",
|
|
"safety_risk",
|
|
"comfort_distance",
|
|
"discovery_priority"
|
|
]
|
|
)
|
|
)
|
|
_check(candidate_metadata_valid, "Adapter resource facts should include discovery metadata")
|
|
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
|
manager.debug_logs = false
|
|
manager.active_world_adapter = adapter
|
|
root.add_child(manager)
|
|
manager.set_process(false)
|
|
await process_frame
|
|
var npc: SimNPC = manager.npcs[0]
|
|
npc.set_task(SimulationIds.ACTION_GATHER_FOOD)
|
|
_check(
|
|
manager.resolve_npc_target(npc.id, Vector3.ZERO),
|
|
"Target resolver should find Jajce resources without a parent path"
|
|
)
|
|
_check(
|
|
npc.target_id != &"animal_camp_river_01",
|
|
"Target resolver should not choose the far risky animal camp while safer food exists"
|
|
)
|
|
var selected := ResourceNode.get_by_id(npc.target_id)
|
|
if selected != null:
|
|
var selected_state: ResourceStateRecord = manager.get_resource_state(selected.node_id)
|
|
_check(
|
|
selected_state.get_reserved_by() == npc.id,
|
|
"Selected Jajce resource should be authoritatively reserved"
|
|
)
|
|
manager.release_resource(selected.node_id, npc.id)
|
|
var storage_target := adapter.get_activity_target(SimulationIds.ACTION_WITHDRAW_FOOD)
|
|
_check(
|
|
storage_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_PANTRY),
|
|
"Storage actions should target the typed pantry's stable ID"
|
|
)
|
|
var storage_target_position: Vector3 = storage_target.get("position", Vector3(9999, 9999, 9999))
|
|
_check(
|
|
storage_target_position.distance_to(pantry.get_interaction_position()) < 0.01,
|
|
"Storage actions should use the pantry StorageNode interaction point"
|
|
)
|
|
var wood_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_WOOD)
|
|
_check(
|
|
wood_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_WOODPILE),
|
|
"Wood deposit should target the typed woodpile's stable ID"
|
|
)
|
|
var rest_target := adapter.get_activity_target(SimulationIds.ACTION_REST)
|
|
_check(
|
|
rest_target.get("target_id", "") == "rest_bench",
|
|
"Rest should target the typed RestBench ActivitySite"
|
|
)
|
|
|
|
if failures.is_empty():
|
|
print("[TEST] Jajce scaffold passed: Terrain3D, scattered 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, "Jajce navigation map did not synchronize within 30 physics frames")
|
|
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append(message)
|
|
|
|
|
|
func _collect_resource_nodes(parent: Node) -> Array[ResourceNode]:
|
|
var resources: Array[ResourceNode] = []
|
|
for child in parent.get_children():
|
|
if child is ResourceNode:
|
|
resources.append(child)
|
|
else:
|
|
resources.append_array(_collect_resource_nodes(child))
|
|
return resources
|
|
|
|
|
|
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]
|
|
)
|