feat: add witnessed knowledge and wind ambience
This commit is contained in:
@@ -85,9 +85,7 @@ func _test_selection_and_execution_are_separate() -> void:
|
||||
_check(
|
||||
(
|
||||
unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL)
|
||||
and "Needs 1 Wood" in String(
|
||||
unfunded_selection.rejections[SimulationIds.ACTION_PATROL]
|
||||
)
|
||||
and "Needs 1 Wood" in String(unfunded_selection.rejections[SimulationIds.ACTION_PATROL])
|
||||
),
|
||||
"Selection should expose a readable definition-backed rejection reason"
|
||||
)
|
||||
|
||||
@@ -208,8 +208,7 @@ func _test_wood_work_requires_material() -> void:
|
||||
_check(
|
||||
(
|
||||
not blocked_events.is_empty()
|
||||
and StringName(blocked_events[0].data["event_type"])
|
||||
== SimulationIds.EVENT_TASK_BLOCKED
|
||||
and StringName(blocked_events[0].data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
|
||||
and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", ""))
|
||||
),
|
||||
"An unpaid completion cost should create a readable blocked-task fact"
|
||||
|
||||
@@ -16,8 +16,56 @@ func _run() -> void:
|
||||
|
||||
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]
|
||||
contributor.position = Vector3.ZERO
|
||||
witness.position = Vector3(4.0, 0.0, 0.0)
|
||||
witness.hunger = 85.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 village_ui := main_scene.get_node("UI")
|
||||
village_ui.selected_npc_index = witness.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
(
|
||||
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text
|
||||
and "Relationship: %s" % contributor.npc_name in inspector_label.text
|
||||
and "Because:" in inspector_label.text
|
||||
),
|
||||
"NPC inspector should show the witnessed fact and its relationship consequence"
|
||||
)
|
||||
_check(
|
||||
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
|
||||
"Playable runtime should instance the Jajce Terrain3D world"
|
||||
@@ -39,6 +87,21 @@ func _run() -> void:
|
||||
"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"
|
||||
@@ -53,7 +116,9 @@ func _run() -> void:
|
||||
"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(
|
||||
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"
|
||||
@@ -63,7 +128,9 @@ func _run() -> void:
|
||||
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(
|
||||
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"
|
||||
@@ -86,9 +153,14 @@ func _run() -> void:
|
||||
)
|
||||
|
||||
var navigation_map: RID = main_scene.get_world_3d().navigation_map
|
||||
var navigation_region := main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
|
||||
var navigation_region := (
|
||||
main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
|
||||
)
|
||||
_check(
|
||||
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres",
|
||||
(
|
||||
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)
|
||||
@@ -104,13 +176,17 @@ func _run() -> void:
|
||||
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
|
||||
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
|
||||
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")
|
||||
@@ -193,7 +269,9 @@ func _check(condition: bool, message: String) -> void:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void:
|
||||
func _check_path_tracks_terrain(
|
||||
terrain: Terrain3D, path: PackedVector3Array, message: String
|
||||
) -> void:
|
||||
if path.is_empty():
|
||||
return
|
||||
|
||||
@@ -204,13 +282,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
|
||||
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]
|
||||
(
|
||||
"%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:
|
||||
func _check_path_reaches_destination(
|
||||
path: PackedVector3Array, destination: Vector3, message: String
|
||||
) -> void:
|
||||
if path.is_empty():
|
||||
return
|
||||
|
||||
@@ -219,6 +301,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
|
||||
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]
|
||||
"%s: final path point %s is too far from %s" % [message, final_point, destination]
|
||||
)
|
||||
|
||||
@@ -67,6 +67,94 @@ func _run() -> void:
|
||||
world.get_node("FoliageRoot").get_child_count() >= 10,
|
||||
"JajceWorld should include restrained authored foliage clusters"
|
||||
)
|
||||
_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("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop")
|
||||
_check(
|
||||
@@ -91,9 +179,11 @@ func _run() -> void:
|
||||
"Jajce environment should provide sky and restrained valley fog"
|
||||
)
|
||||
_check(
|
||||
environment.glow_enabled
|
||||
and environment.volumetric_fog_enabled
|
||||
and environment.adjustment_enabled,
|
||||
(
|
||||
environment.glow_enabled
|
||||
and environment.volumetric_fog_enabled
|
||||
and environment.adjustment_enabled
|
||||
),
|
||||
"WorldEnvironment should provide bounded glow, depth haze, and color grading"
|
||||
)
|
||||
_check(
|
||||
@@ -102,24 +192,47 @@ func _run() -> void:
|
||||
)
|
||||
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")
|
||||
@@ -131,27 +244,29 @@ func _run() -> void:
|
||||
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"
|
||||
],
|
||||
(
|
||||
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
|
||||
@@ -190,7 +305,9 @@ func _run() -> void:
|
||||
_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(
|
||||
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")
|
||||
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
|
||||
@@ -232,7 +349,10 @@ func _run() -> void:
|
||||
"Jajce navigation mesh should contain baked polygons"
|
||||
)
|
||||
_check(
|
||||
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres",
|
||||
(
|
||||
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)
|
||||
@@ -329,9 +449,7 @@ func _run() -> void:
|
||||
)
|
||||
|
||||
if failures.is_empty():
|
||||
print(
|
||||
"[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites"
|
||||
)
|
||||
print("[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites")
|
||||
quit(0)
|
||||
return
|
||||
|
||||
@@ -357,7 +475,9 @@ func _check(condition: bool, message: String) -> void:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void:
|
||||
func _check_path_tracks_terrain(
|
||||
terrain: Terrain3D, path: PackedVector3Array, message: String
|
||||
) -> void:
|
||||
if path.is_empty():
|
||||
return
|
||||
|
||||
@@ -368,13 +488,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
|
||||
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]
|
||||
(
|
||||
"%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:
|
||||
func _check_path_reaches_destination(
|
||||
path: PackedVector3Array, destination: Vector3, message: String
|
||||
) -> void:
|
||||
if path.is_empty():
|
||||
return
|
||||
|
||||
@@ -383,6 +507,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
|
||||
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]
|
||||
"%s: final path point %s is too far from %s" % [message, final_point, destination]
|
||||
)
|
||||
|
||||
+28
-27
@@ -64,19 +64,14 @@ func _test_work_period_during_day() -> void:
|
||||
manager.clock.cycle_duration_seconds = 240.0
|
||||
manager.clock.elapsed_ticks = 100
|
||||
var time_of_day: float = manager.clock.time_of_day()
|
||||
_check(
|
||||
time_of_day > 0.28 and time_of_day < 0.85,
|
||||
"100 ticks should land in the work period"
|
||||
)
|
||||
_check(time_of_day > 0.28 and time_of_day < 0.85, "100 ticks should land in the work period")
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
npc.profession = SimulationIds.PROFESSION_WANDERER
|
||||
npc.hunger = 20.0
|
||||
npc.energy = 80.0
|
||||
npc.home_position = npc.position
|
||||
|
||||
var selection := ActionSelectionSystem.new().select_action(
|
||||
npc, manager.village, time_of_day
|
||||
)
|
||||
var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
|
||||
_check(selection != null, "Daytime should produce an action selection")
|
||||
_check(
|
||||
selection.action_id != SimulationIds.ACTION_SLEEP,
|
||||
@@ -101,9 +96,7 @@ func _test_meal_period_hunger() -> void:
|
||||
manager.village.food = 5.0
|
||||
npc.home_position = npc.position
|
||||
|
||||
var selection := ActionSelectionSystem.new().select_action(
|
||||
npc, manager.village, time_of_day
|
||||
)
|
||||
var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
|
||||
_check(selection != null, "Meal period should produce an action selection")
|
||||
_check(
|
||||
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
|
||||
@@ -129,10 +122,7 @@ func _test_sleep_restores_energy() -> void:
|
||||
manager.simulate_tick()
|
||||
|
||||
_check(npc.task_complete, "Sleep should complete after its duration")
|
||||
_check(
|
||||
npc.energy > energy_before,
|
||||
"Completing sleep should restore energy"
|
||||
)
|
||||
_check(npc.energy > energy_before, "Completing sleep should restore energy")
|
||||
manager.free()
|
||||
|
||||
|
||||
@@ -206,20 +196,26 @@ func _test_household_familiarity() -> void:
|
||||
var d: SimNPC = manager.npcs[3]
|
||||
var e: SimNPC = manager.npcs[4]
|
||||
|
||||
var a_to_b: RelationshipStateRecord = manager.relationship_system.get_relationship(a.id, b.id)
|
||||
var b_to_a: RelationshipStateRecord = manager.relationship_system.get_relationship(b.id, a.id)
|
||||
_check(a_to_b != null and b_to_a != null, "Nearby homes should create directed household ties")
|
||||
if a_to_b != null:
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(a_to_b.get_familiarity(), 0.5)
|
||||
and is_equal_approx(a_to_b.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST)
|
||||
),
|
||||
"Household familiarity should start at baseline 0.5"
|
||||
)
|
||||
_check(
|
||||
a.familiarity.has(b.id) and b.familiarity.has(a.id),
|
||||
"NPCs with nearby homes should be mutual household members"
|
||||
)
|
||||
_check(
|
||||
is_equal_approx(float(a.familiarity[b.id]), 0.5),
|
||||
"Household familiarity should start at baseline 0.5"
|
||||
)
|
||||
_check(
|
||||
c.familiarity.has(d.id) and d.familiarity.has(c.id),
|
||||
(
|
||||
manager.relationship_system.get_relationship(c.id, d.id) != null
|
||||
and manager.relationship_system.get_relationship(d.id, c.id) != null
|
||||
),
|
||||
"Second household pair should also be familiar"
|
||||
)
|
||||
_check(
|
||||
not a.familiarity.has(e.id),
|
||||
manager.relationship_system.get_relationship(a.id, e.id) == null,
|
||||
"Distant NPCs should not have household familiarity"
|
||||
)
|
||||
|
||||
@@ -232,10 +228,15 @@ func _test_household_familiarity() -> void:
|
||||
restored.restore_state_from_json(saved_json),
|
||||
"Familiarity should survive save/load round-trip"
|
||||
)
|
||||
var ra: SimNPC = restored.npcs[0]
|
||||
var restored_relationship: RelationshipStateRecord = (
|
||||
restored.relationship_system.get_relationship(a.id, b.id)
|
||||
)
|
||||
_check(
|
||||
ra.familiarity.size() > 0,
|
||||
"Restored NPC should retain household familiarity"
|
||||
(
|
||||
restored_relationship != null
|
||||
and is_equal_approx(restored_relationship.get_familiarity(), 0.5)
|
||||
),
|
||||
"Restored simulation should retain household familiarity"
|
||||
)
|
||||
|
||||
manager.free()
|
||||
|
||||
@@ -37,20 +37,23 @@ func _run() -> void:
|
||||
"NPC presentation should use a readable multi-part villager silhouette"
|
||||
)
|
||||
_check(
|
||||
visual.get_node("ProfessionProp").mesh != null
|
||||
and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
|
||||
(
|
||||
visual.get_node("ProfessionProp").mesh != null
|
||||
and profession_definition.display_name in visual.get_node("ProfessionLabel").text
|
||||
),
|
||||
"NPC visual should expose a profession prop and readable label"
|
||||
)
|
||||
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
|
||||
_check(
|
||||
visual.get_node("TaskGlyphRoot").visible
|
||||
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh,
|
||||
(
|
||||
visual.get_node("TaskGlyphRoot").visible
|
||||
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh
|
||||
),
|
||||
"NPC visual should expose a compact task glyph for cinematic readability"
|
||||
)
|
||||
visual.set_debug_overlay_visible(false)
|
||||
_check(
|
||||
not visual.get_node("ProfessionLabel").visible
|
||||
and visual.get_node("TaskGlyphRoot").visible,
|
||||
not visual.get_node("ProfessionLabel").visible and visual.get_node("TaskGlyphRoot").visible,
|
||||
"Cinematic mode should hide debug labels while preserving task glyphs"
|
||||
)
|
||||
visual.set_debug_overlay_visible(true)
|
||||
@@ -68,12 +71,9 @@ func _run() -> void:
|
||||
)
|
||||
manager.latest_decisions[npc.id] = test_decision
|
||||
manager.emit_signal("npc_decision_recorded", npc, test_decision)
|
||||
var inspector_label: Label = ui.get_node(
|
||||
"NpcInspectorPanel/MarginContainer/NpcInspectorLabel"
|
||||
)
|
||||
var inspector_label: Label = ui.get_node("NpcInspectorPanel/MarginContainer/NpcInspectorLabel")
|
||||
_check(
|
||||
"Test shortage reason" in inspector_label.text
|
||||
and npc.npc_name in inspector_label.text,
|
||||
"Test shortage reason" in inspector_label.text and npc.npc_name in inspector_label.text,
|
||||
"NPC inspector should show the selected villager's real decision reason"
|
||||
)
|
||||
_check(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var manager := _create_manager()
|
||||
var contributor: SimNPC = manager.npcs[0]
|
||||
var observer: SimNPC = manager.npcs[1]
|
||||
var stranger: SimNPC = manager.npcs[2]
|
||||
var relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
|
||||
observer.id, contributor.id
|
||||
)
|
||||
_check(relationship != null, "Nearby household NPCs should begin with a directed relationship")
|
||||
if relationship == null:
|
||||
manager.free()
|
||||
_finish()
|
||||
return
|
||||
|
||||
observer.hunger = 85.0
|
||||
stranger.hunger = 85.0
|
||||
contributor.position = Vector3.ZERO
|
||||
observer.position = Vector3(4.0, 0.0, 0.0)
|
||||
stranger.position = Vector3(20.0, 0.0, 0.0)
|
||||
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
|
||||
var deposited: float = manager.economy.deposit_inventory(
|
||||
contributor, SimulationIds.RESOURCE_FOOD
|
||||
)
|
||||
_check(is_equal_approx(deposited, 2.0), "The real economy path should deposit contributed food")
|
||||
|
||||
var cause_event: EconomicEventRecord = manager.get_relationship_cause(relationship)
|
||||
var cause_event_id := int(cause_event.data["event_id"]) if cause_event != null else -1
|
||||
_check(
|
||||
is_equal_approx(relationship.get_trust(), 0.65),
|
||||
"Food aid should raise the hungry familiar NPC's directed trust"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
cause_event != null
|
||||
and StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||
and int(cause_event.data["actor_id"]) == contributor.id
|
||||
),
|
||||
"Trust should reference the exact structured deposit event that caused it"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
manager.npc_knows_event(contributor.id, cause_event_id)
|
||||
and manager.npc_knows_event(observer.id, cause_event_id)
|
||||
and not manager.npc_knows_event(stranger.id, cause_event_id)
|
||||
),
|
||||
"Only the contributor and nearby observer should know the deposit fact"
|
||||
)
|
||||
var reverse: RelationshipStateRecord = manager.relationship_system.get_relationship(
|
||||
contributor.id, observer.id
|
||||
)
|
||||
_check(
|
||||
(
|
||||
reverse != null
|
||||
and is_equal_approx(reverse.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST)
|
||||
),
|
||||
"Food aid trust should remain directed instead of changing both people implicitly"
|
||||
)
|
||||
_check(
|
||||
manager.relationship_system.get_relationship(stranger.id, contributor.id) == null,
|
||||
"A distant NPC should not gain trust without an existing familiar relationship"
|
||||
)
|
||||
|
||||
var pantry: StorageStateRecord = manager.get_pantry()
|
||||
pantry.withdraw(
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
maxf(
|
||||
(
|
||||
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
- (ActionSelectionSystem.RELATIONSHIP_AID_PANTRY_THRESHOLD - 5.0)
|
||||
),
|
||||
0.0
|
||||
)
|
||||
)
|
||||
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||
contributor.hunger = 95.0
|
||||
contributor.is_starving = true
|
||||
observer.profession = SimulationIds.PROFESSION_GUARD
|
||||
observer.hunger = 20.0
|
||||
observer.energy = 80.0
|
||||
observer.inventory.clear()
|
||||
observer.last_task = &""
|
||||
manager.economy.deposit_resource(SimulationIds.RESOURCE_WOOD, 10.0)
|
||||
manager.village.safety = 50.0
|
||||
manager.village.knowledge = 20.0
|
||||
manager.village.update_priorities()
|
||||
var neutral_choice: ActionSelectionResult = ActionSelectionSystem.new().select_action(
|
||||
observer, manager.village, 0.5, manager.npcs
|
||||
)
|
||||
var helping_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||
observer, manager.village, 0.5, manager.npcs
|
||||
)
|
||||
_check(
|
||||
neutral_choice.action_id == SimulationIds.ACTION_PATROL,
|
||||
"Without relationship context, the guard should keep doing ordinary patrol work"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
helping_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||
and contributor.npc_name in helping_choice.reason
|
||||
),
|
||||
"Trust should visibly redirect ordinary work toward helping a starving acquaintance"
|
||||
)
|
||||
|
||||
var saved_json: String = manager.serialize_state()
|
||||
var restored := _create_manager(999)
|
||||
_check(
|
||||
restored.restore_state_from_json(saved_json),
|
||||
"Relationship state and its event cause should restore through the world schema"
|
||||
)
|
||||
var restored_relationship: RelationshipStateRecord = (
|
||||
restored.relationship_system.get_relationship(observer.id, contributor.id)
|
||||
)
|
||||
_check(
|
||||
(
|
||||
restored_relationship != null
|
||||
and is_equal_approx(restored_relationship.get_trust(), relationship.get_trust())
|
||||
and (
|
||||
restored_relationship.get_last_trust_cause_event_id()
|
||||
== relationship.get_last_trust_cause_event_id()
|
||||
)
|
||||
),
|
||||
"Save/load should preserve directed trust and its causal event identity"
|
||||
)
|
||||
_check(
|
||||
restored.get_state_checksum() == manager.get_state_checksum(),
|
||||
"Restored relationship state should retain the complete deterministic checksum"
|
||||
)
|
||||
|
||||
manager.free()
|
||||
restored.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Relationship consequence passed: food aid -> trust -> helping choice")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func _create_manager(seed_value: int = 801) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
manager.debug_logs = false
|
||||
var home_positions: Array[Vector3] = [
|
||||
Vector3(0.0, 0.0, 0.0),
|
||||
Vector3(1.0, 0.0, 0.0),
|
||||
Vector3(20.0, 0.0, 20.0),
|
||||
Vector3(30.0, 0.0, 30.0),
|
||||
Vector3(40.0, 0.0, 40.0),
|
||||
Vector3(50.0, 0.0, 50.0),
|
||||
]
|
||||
manager.home_positions = home_positions
|
||||
root.add_child(manager)
|
||||
manager.set_process(false)
|
||||
return manager
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bt4ajli015mp6
|
||||
@@ -42,6 +42,28 @@ func _run() -> void:
|
||||
"Village should receive exactly the bush's remaining food"
|
||||
)
|
||||
_check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation")
|
||||
var player_extraction: EconomicEventRecord
|
||||
var player_depletion: EconomicEventRecord
|
||||
for event in simulation_manager.economic_events:
|
||||
if int(event.data["actor_id"]) != -1 or StringName(event.data["source_id"]) != bush.node_id:
|
||||
continue
|
||||
if StringName(event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED:
|
||||
player_extraction = event
|
||||
elif StringName(event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_DEPLETED:
|
||||
player_depletion = event
|
||||
var bush_event_position := bush.interaction_point.global_position
|
||||
_check(player_extraction != null, "Player extraction should create a structured event")
|
||||
_check(player_depletion != null, "Player depletion should create a structured event")
|
||||
if player_extraction != null:
|
||||
_check(
|
||||
player_extraction.get_world_position().is_equal_approx(bush_event_position),
|
||||
"Player extraction should preserve the resource interaction position"
|
||||
)
|
||||
if player_depletion != null:
|
||||
_check(
|
||||
player_depletion.get_world_position().is_equal_approx(bush_event_position),
|
||||
"Player depletion should preserve the resource interaction position"
|
||||
)
|
||||
|
||||
player.global_position = tree.interaction_point.global_position
|
||||
var wood_before: float = simulation_manager.village.wood
|
||||
@@ -57,18 +79,20 @@ func _run() -> void:
|
||||
"Village should receive exactly the extracted wood"
|
||||
)
|
||||
|
||||
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
||||
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
|
||||
pantry_state.deposit(
|
||||
SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5
|
||||
var pantry := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
||||
)
|
||||
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
|
||||
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||
bush_state.set_amount_remaining(1.0)
|
||||
player.global_position = bush.interaction_point.global_position
|
||||
player.try_interact()
|
||||
_check(
|
||||
is_equal_approx(bush_state.get_amount_remaining(), 0.5)
|
||||
and is_equal_approx(pantry_state.get_available_capacity(), 0.0),
|
||||
(
|
||||
is_equal_approx(bush_state.get_amount_remaining(), 0.5)
|
||||
and is_equal_approx(pantry_state.get_available_capacity(), 0.0)
|
||||
),
|
||||
"Player harvesting should leave overflow at its source when storage fills"
|
||||
)
|
||||
|
||||
@@ -81,7 +105,9 @@ func _run() -> void:
|
||||
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before,
|
||||
"Player should eat from the typed pantry StorageNode"
|
||||
)
|
||||
var guard_site := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
|
||||
var guard_site := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
|
||||
)
|
||||
player.global_position = guard_site.get_interaction_position()
|
||||
var safety_before: float = simulation_manager.village.safety
|
||||
player.try_interact()
|
||||
|
||||
@@ -34,17 +34,16 @@ func _test_registry_integrity() -> void:
|
||||
SimulationDefinitions.get_action(definition.action_id) == definition,
|
||||
"Action lookup should return '%s'" % definition.action_id
|
||||
)
|
||||
_check(action_ids.size() == 11, "Registry should contain all eleven executable prototype actions")
|
||||
_check(
|
||||
action_ids.size() == 11, "Registry should contain all eleven executable prototype actions"
|
||||
)
|
||||
|
||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
||||
var profession_colors := {}
|
||||
for profession_id in profession_ids:
|
||||
var profession := SimulationDefinitions.get_profession(profession_id)
|
||||
_check(
|
||||
profession != null,
|
||||
"Profession lookup should return '%s'" % profession_id
|
||||
)
|
||||
_check(profession != null, "Profession lookup should return '%s'" % profession_id)
|
||||
if profession != null:
|
||||
profession_colors[profession.visual_color.to_html()] = true
|
||||
_check(
|
||||
|
||||
@@ -14,6 +14,9 @@ func _run() -> void:
|
||||
_test_legacy_resource_migration()
|
||||
_test_legacy_world_storage_migration()
|
||||
_test_previous_world_event_migration()
|
||||
_test_previous_world_relationship_migration()
|
||||
_test_previous_world_knowledge_migration()
|
||||
_test_relationship_schema_rejection()
|
||||
_test_schema_rejection()
|
||||
|
||||
if failures.is_empty():
|
||||
@@ -157,8 +160,10 @@ func _test_legacy_resource_migration() -> void:
|
||||
"Resource migration should preserve mutable authority"
|
||||
)
|
||||
_check(
|
||||
is_equal_approx(migrated.get_safety_risk(), 0.0)
|
||||
and is_equal_approx(migrated.get_comfort_distance(), 18.0),
|
||||
(
|
||||
is_equal_approx(migrated.get_safety_risk(), 0.0)
|
||||
and is_equal_approx(migrated.get_comfort_distance(), 18.0)
|
||||
),
|
||||
"Resource migration should default discovery metadata"
|
||||
)
|
||||
|
||||
@@ -190,9 +195,7 @@ func _test_legacy_world_storage_migration() -> void:
|
||||
var migrated_storages := {}
|
||||
for storage in migrated.storages:
|
||||
migrated_storages[storage.get_storage_id()] = storage
|
||||
var pantry: StorageStateRecord = migrated_storages.get(
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
)
|
||||
var pantry: StorageStateRecord = migrated_storages.get(SimulationIds.STORAGE_VILLAGE_PANTRY)
|
||||
var woodpile: StorageStateRecord = migrated_storages.get(
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
)
|
||||
@@ -200,8 +203,12 @@ func _test_legacy_world_storage_migration() -> void:
|
||||
(
|
||||
pantry != null
|
||||
and woodpile != null
|
||||
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food)
|
||||
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood)
|
||||
and is_equal_approx(
|
||||
pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food
|
||||
)
|
||||
and is_equal_approx(
|
||||
woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood
|
||||
)
|
||||
),
|
||||
"World migration should preserve legacy village resources in their storage"
|
||||
)
|
||||
@@ -211,7 +218,7 @@ func _test_legacy_world_storage_migration() -> void:
|
||||
func _test_previous_world_event_migration() -> void:
|
||||
var manager := _create_manager(56)
|
||||
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_data["schema_version"] = (SimulationStateRecord.PREVIOUS_SCHEMA_VERSION)
|
||||
previous_data["schema_version"] = SimulationStateRecord.EVENT_LEGACY_SCHEMA_VERSION
|
||||
previous_data.erase("economic_events")
|
||||
previous_data["simulation"].erase("next_event_id")
|
||||
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||
@@ -224,6 +231,169 @@ func _test_previous_world_event_migration() -> void:
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_previous_world_relationship_migration() -> void:
|
||||
var manager := _create_manager(57)
|
||||
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_data["schema_version"] = SimulationStateRecord.RELATIONSHIP_LEGACY_SCHEMA_VERSION
|
||||
previous_data.erase("relationships")
|
||||
previous_data.erase("event_knowledge")
|
||||
for npc_data in previous_data["npcs"]:
|
||||
npc_data["schema_version"] = NPCStateRecord.RELATIONSHIP_LEGACY_SCHEMA_VERSION
|
||||
npc_data["familiarity"] = []
|
||||
previous_data["npcs"][0]["familiarity"] = [{"id": 1, "score": 0.75}]
|
||||
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||
_check(migrated != null, "World schema v3 should migrate NPC familiarity into relationships")
|
||||
if migrated != null:
|
||||
_check(
|
||||
(
|
||||
migrated.relationships.size() == 1
|
||||
and migrated.relationships[0].get_observer_id() == 0
|
||||
and migrated.relationships[0].get_subject_id() == 1
|
||||
and is_equal_approx(migrated.relationships[0].get_familiarity(), 0.75)
|
||||
and is_equal_approx(
|
||||
migrated.relationships[0].get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
|
||||
)
|
||||
),
|
||||
"World v3 migration should preserve familiarity and initialize neutral trust"
|
||||
)
|
||||
_check(
|
||||
not migrated.npcs[0].data.has("familiarity"),
|
||||
"World v3 migration should remove obsolete NPC-local familiarity data"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_previous_world_knowledge_migration() -> void:
|
||||
var manager := _create_manager(59)
|
||||
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
|
||||
previous_data.erase("event_knowledge")
|
||||
var deposit_event := EconomicEventRecord.create(
|
||||
0,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
12,
|
||||
1,
|
||||
SimulationIds.npc_inventory_id(1),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
2.0
|
||||
)
|
||||
previous_data["economic_events"] = [deposit_event.to_dictionary()]
|
||||
previous_data["simulation"]["next_event_id"] = 1
|
||||
previous_data["relationships"] = [
|
||||
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
|
||||
]
|
||||
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||
_check(migrated != null, "World schema v4 should migrate causal facts into NPC knowledge")
|
||||
if migrated != null:
|
||||
_check(
|
||||
(
|
||||
migrated.event_knowledge.size() == 1
|
||||
and migrated.event_knowledge[0].get_knower_id() == 0
|
||||
and migrated.event_knowledge[0].get_event_id() == 0
|
||||
),
|
||||
"World v4 migration should preserve the fact implied by a relationship cause"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_relationship_schema_rejection() -> void:
|
||||
var manager := _create_manager(58)
|
||||
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
missing_cause_data["relationships"] = [
|
||||
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 999).to_dictionary()
|
||||
]
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(missing_cause_data) == null,
|
||||
"Relationship causes must reference an event in the same world record"
|
||||
)
|
||||
|
||||
var duplicate_pair_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
var relationship_data := RelationshipStateRecord.create(0, 1, 0.5).to_dictionary()
|
||||
duplicate_pair_data["relationships"] = [relationship_data, relationship_data.duplicate(true)]
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(duplicate_pair_data) == null,
|
||||
"World state should reject duplicate directed relationship pairs"
|
||||
)
|
||||
|
||||
var unknown_knowledge_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
unknown_knowledge_data["event_knowledge"] = [
|
||||
KnownEventStateRecord.create(0, 999).to_dictionary()
|
||||
]
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(unknown_knowledge_data) == null,
|
||||
"Known-event records must reference an event in the same world record"
|
||||
)
|
||||
var duplicate_knowledge_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
var event := EconomicEventRecord.create(
|
||||
0,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
1,
|
||||
0,
|
||||
SimulationIds.npc_inventory_id(0),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
duplicate_knowledge_data["economic_events"] = [event.to_dictionary()]
|
||||
duplicate_knowledge_data["simulation"]["next_event_id"] = 1
|
||||
var known_event_data := KnownEventStateRecord.create(0, 0).to_dictionary()
|
||||
duplicate_knowledge_data["event_knowledge"] = [
|
||||
known_event_data, known_event_data.duplicate(true)
|
||||
]
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(duplicate_knowledge_data) == null,
|
||||
"World state should reject duplicate NPC knowledge pairs"
|
||||
)
|
||||
|
||||
var wrong_subject_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
var unrelated_deposit := EconomicEventRecord.create(
|
||||
0,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
1,
|
||||
2,
|
||||
SimulationIds.npc_inventory_id(2),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
wrong_subject_cause_data["economic_events"] = [unrelated_deposit.to_dictionary()]
|
||||
wrong_subject_cause_data["simulation"]["next_event_id"] = 1
|
||||
wrong_subject_cause_data["event_knowledge"] = [
|
||||
KnownEventStateRecord.create(0, 0).to_dictionary()
|
||||
]
|
||||
wrong_subject_cause_data["relationships"] = [
|
||||
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
|
||||
]
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(wrong_subject_cause_data) == null,
|
||||
"A trust cause must be a known food deposit performed by the relationship subject"
|
||||
)
|
||||
|
||||
var nonpositive_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
var nonpositive_deposit := EconomicEventRecord.create(
|
||||
0,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
1,
|
||||
1,
|
||||
SimulationIds.npc_inventory_id(1),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
0.0
|
||||
)
|
||||
nonpositive_cause_data["economic_events"] = [nonpositive_deposit.to_dictionary()]
|
||||
nonpositive_cause_data["simulation"]["next_event_id"] = 1
|
||||
nonpositive_cause_data["event_knowledge"] = [KnownEventStateRecord.create(0, 0).to_dictionary()]
|
||||
nonpositive_cause_data["relationships"] = [
|
||||
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
|
||||
]
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(nonpositive_cause_data) == null,
|
||||
"A trust cause must describe a successful positive food deposit"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _create_manager(seed_value: int) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
|
||||
@@ -2,6 +2,8 @@ extends GutTest
|
||||
|
||||
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
||||
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||
|
||||
|
||||
func test_storage_never_accepts_more_than_its_capacity() -> void:
|
||||
@@ -45,3 +47,84 @@ func test_event_log_preserves_order_and_derives_consumption_rate() -> void:
|
||||
assert_eq(int(event_log.events[1].data["event_id"]), 1)
|
||||
assert_eq(event_log.get_for_actor(4).size(), 2)
|
||||
assert_eq(float(event_log.get_consumption_rates(200)["food_per_day"]), 1.0)
|
||||
var legacy_event_data := event_log.events[1].to_dictionary()
|
||||
legacy_event_data["schema_version"] = EconomicEventRecord.LEGACY_SCHEMA_VERSION
|
||||
legacy_event_data.erase("world_position")
|
||||
var migrated_event := EconomicEventRecord.from_dictionary(legacy_event_data)
|
||||
assert_not_null(migrated_event)
|
||||
assert_eq(migrated_event.get_world_position(), Vector3.ZERO)
|
||||
|
||||
|
||||
func test_food_aid_builds_directed_trust_once_per_event() -> void:
|
||||
var contributor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
|
||||
var observer := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
|
||||
contributor.home_position = Vector3.ZERO
|
||||
observer.home_position = Vector3(1.0, 0.0, 0.0)
|
||||
observer.hunger = 85.0
|
||||
var villagers: Array[SimNPC] = [contributor, observer]
|
||||
var relationship_system := RelationshipSystemScript.new()
|
||||
relationship_system.initialize_households(villagers)
|
||||
var deposit_event := EconomicEventRecord.create(
|
||||
7,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
12,
|
||||
contributor.id,
|
||||
SimulationIds.npc_inventory_id(contributor.id),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
2.0
|
||||
)
|
||||
var later_deposit_event := EconomicEventRecord.create(
|
||||
8,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
13,
|
||||
contributor.id,
|
||||
SimulationIds.npc_inventory_id(contributor.id),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
|
||||
var knower_ids: Array[int] = [contributor.id, observer.id]
|
||||
relationship_system.apply_event(deposit_event, villagers, knower_ids)
|
||||
relationship_system.apply_event(later_deposit_event, villagers, knower_ids)
|
||||
relationship_system.apply_event(deposit_event, villagers, knower_ids)
|
||||
var relationship: RelationshipStateRecord = relationship_system.get_relationship(
|
||||
observer.id, contributor.id
|
||||
)
|
||||
assert_eq(relationship.get_trust(), 0.8)
|
||||
assert_eq(relationship.get_last_trust_cause_event_id(), 8)
|
||||
assert_eq(
|
||||
relationship_system.get_relationship(contributor.id, observer.id).get_trust(),
|
||||
RelationshipStateRecord.NEUTRAL_TRUST
|
||||
)
|
||||
|
||||
|
||||
func test_event_knowledge_is_spatial_directed_and_idempotent() -> void:
|
||||
var actor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
|
||||
var nearby := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
|
||||
var distant := SimNPC.new(2, "Jasmin", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
|
||||
actor.position = Vector3.ZERO
|
||||
nearby.position = Vector3(4.0, 0.0, 0.0)
|
||||
distant.position = Vector3(20.0, 0.0, 0.0)
|
||||
var villagers: Array[SimNPC] = [actor, nearby, distant]
|
||||
var event := EconomicEventRecord.create(
|
||||
12,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
30,
|
||||
actor.id,
|
||||
SimulationIds.npc_inventory_id(actor.id),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
2.0,
|
||||
Vector3.ZERO
|
||||
)
|
||||
actor.position = Vector3(100.0, 0.0, 0.0)
|
||||
var knowledge_system := EventKnowledgeSystemScript.new()
|
||||
|
||||
assert_eq(knowledge_system.observe_event(event, villagers).size(), 2)
|
||||
assert_eq(knowledge_system.observe_event(event, villagers).size(), 0)
|
||||
assert_true(knowledge_system.knows_event(actor.id, 12))
|
||||
assert_true(knowledge_system.knows_event(nearby.id, 12))
|
||||
assert_false(knowledge_system.knows_event(distant.id, 12))
|
||||
assert_eq(knowledge_system.get_knowers(12), [actor.id, nearby.id])
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var manager := _create_manager()
|
||||
var contributor: SimNPC = manager.npcs[0]
|
||||
var nearby_observer: SimNPC = manager.npcs[1]
|
||||
var distant_observer: SimNPC = manager.npcs[2]
|
||||
contributor.position = Vector3.ZERO
|
||||
nearby_observer.position = Vector3(4.0, 0.0, 0.0)
|
||||
distant_observer.position = Vector3(20.0, 0.0, 0.0)
|
||||
nearby_observer.hunger = 85.0
|
||||
distant_observer.hunger = 85.0
|
||||
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
|
||||
|
||||
_check(
|
||||
is_equal_approx(
|
||||
manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD), 2.0
|
||||
),
|
||||
"The witnessed fact should originate from a real successful food deposit"
|
||||
)
|
||||
var deposit_events: Array = manager.get_npc_events(contributor.id, 3)
|
||||
var deposit_event: EconomicEventRecord
|
||||
for event in deposit_events:
|
||||
if StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
||||
deposit_event = event
|
||||
break
|
||||
_check(deposit_event != null, "The objective event stream should contain the deposit fact")
|
||||
if deposit_event == null:
|
||||
manager.free()
|
||||
_finish()
|
||||
return
|
||||
var event_id := int(deposit_event.data["event_id"])
|
||||
|
||||
_check(
|
||||
(
|
||||
manager.npc_knows_event(contributor.id, event_id)
|
||||
and manager.npc_knows_event(nearby_observer.id, event_id)
|
||||
and not manager.npc_knows_event(distant_observer.id, event_id)
|
||||
),
|
||||
"Nearby and distant familiar NPCs should retain different evidence about one event"
|
||||
)
|
||||
var nearby_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
|
||||
nearby_observer.id, contributor.id
|
||||
)
|
||||
var distant_relationship: RelationshipStateRecord = (
|
||||
manager.relationship_system.get_relationship(distant_observer.id, contributor.id)
|
||||
)
|
||||
_check(
|
||||
(
|
||||
nearby_relationship != null
|
||||
and is_equal_approx(nearby_relationship.get_trust(), 0.65)
|
||||
and nearby_relationship.get_last_trust_cause_event_id() == event_id
|
||||
),
|
||||
"Witnessed food aid should raise directed trust with the known fact as its cause"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
distant_relationship != null
|
||||
and is_equal_approx(
|
||||
distant_relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
|
||||
)
|
||||
and (
|
||||
distant_relationship.get_last_trust_cause_event_id()
|
||||
== RelationshipStateRecord.NO_CAUSE_EVENT
|
||||
)
|
||||
),
|
||||
"Familiarity alone should not grant trust without evidence"
|
||||
)
|
||||
|
||||
_prepare_choice_divergence(manager, contributor, nearby_observer, distant_observer)
|
||||
var nearby_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||
nearby_observer, manager.village, 0.5, manager.npcs
|
||||
)
|
||||
var distant_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||
distant_observer, manager.village, 0.5, manager.npcs
|
||||
)
|
||||
_check(
|
||||
(
|
||||
nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||
and contributor.npc_name in nearby_choice.reason
|
||||
),
|
||||
"The informed trusted observer should leave ordinary work to help"
|
||||
)
|
||||
_check(
|
||||
distant_choice.action_id == SimulationIds.ACTION_PATROL,
|
||||
"The uninformed neutral observer should continue ordinary guard work"
|
||||
)
|
||||
|
||||
var saved_json: String = manager.serialize_state()
|
||||
var restored := _create_manager(999)
|
||||
_check(
|
||||
restored.restore_state_from_json(saved_json),
|
||||
"Witnessed knowledge should restore through the versioned world schema"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
restored.npc_knows_event(nearby_observer.id, event_id)
|
||||
and not restored.npc_knows_event(distant_observer.id, event_id)
|
||||
and restored.get_known_events(nearby_observer.id, 1).size() == 1
|
||||
),
|
||||
"Save/load should preserve different per-NPC knowledge of the same objective event"
|
||||
)
|
||||
_check(
|
||||
restored.get_state_checksum() == manager.get_state_checksum(),
|
||||
"Restored knowledge should retain the complete deterministic checksum"
|
||||
)
|
||||
|
||||
var current_nearby_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||
nearby_observer, manager.village, 0.5, manager.npcs
|
||||
)
|
||||
var restored_nearby: SimNPC = restored.npcs[nearby_observer.id]
|
||||
var restored_nearby_choice: ActionSelectionResult = restored.action_selector.select_action(
|
||||
restored_nearby, restored.village, 0.5, restored.npcs
|
||||
)
|
||||
_check(
|
||||
(
|
||||
current_nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||
and restored_nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||
and contributor.npc_name in current_nearby_choice.reason
|
||||
and contributor.npc_name in restored_nearby_choice.reason
|
||||
),
|
||||
"The restored informed NPC should retain the named helping branch"
|
||||
)
|
||||
|
||||
var current_future_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||
distant_observer, manager.village, 0.5, manager.npcs
|
||||
)
|
||||
var restored_distant: SimNPC = restored.npcs[distant_observer.id]
|
||||
var restored_future_choice: ActionSelectionResult = restored.action_selector.select_action(
|
||||
restored_distant, restored.village, 0.5, restored.npcs
|
||||
)
|
||||
_check(
|
||||
current_future_choice.action_id == restored_future_choice.action_id,
|
||||
"Knowledge restoration should preserve the next deterministic decision"
|
||||
)
|
||||
_check(
|
||||
restored.get_state_checksum() == manager.get_state_checksum(),
|
||||
"Equivalent future decisions should advance restored RNG state identically"
|
||||
)
|
||||
|
||||
manager.free()
|
||||
restored.free()
|
||||
_finish()
|
||||
|
||||
|
||||
func _prepare_choice_divergence(
|
||||
manager: Node, contributor: SimNPC, nearby: SimNPC, distant: SimNPC
|
||||
) -> void:
|
||||
var pantry: StorageStateRecord = manager.get_pantry()
|
||||
pantry.withdraw(
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
maxf(
|
||||
(
|
||||
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
- (ActionSelectionSystem.RELATIONSHIP_AID_PANTRY_THRESHOLD - 5.0)
|
||||
),
|
||||
0.0
|
||||
)
|
||||
)
|
||||
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||
manager.economy.deposit_resource(SimulationIds.RESOURCE_WOOD, 10.0)
|
||||
manager.village.safety = 50.0
|
||||
manager.village.knowledge = 20.0
|
||||
manager.village.update_priorities()
|
||||
contributor.hunger = 95.0
|
||||
contributor.is_starving = true
|
||||
for observer in [nearby, distant]:
|
||||
observer.profession = SimulationIds.PROFESSION_GUARD
|
||||
observer.hunger = 20.0
|
||||
observer.energy = 80.0
|
||||
observer.inventory.clear()
|
||||
observer.last_task = &""
|
||||
|
||||
|
||||
func _create_manager(seed_value: int = 811) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
manager.debug_logs = false
|
||||
var home_positions: Array[Vector3] = [
|
||||
Vector3(0.0, 0.0, 0.0),
|
||||
Vector3(1.0, 0.0, 0.0),
|
||||
Vector3(2.5, 0.0, 0.0),
|
||||
Vector3(20.0, 0.0, 20.0),
|
||||
Vector3(30.0, 0.0, 30.0),
|
||||
Vector3(40.0, 0.0, 40.0),
|
||||
]
|
||||
manager.home_positions = home_positions
|
||||
root.add_child(manager)
|
||||
manager.set_process(false)
|
||||
return manager
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Witnessed knowledge passed: evidence -> trust -> divergent choices")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://m0x2wooal8pa
|
||||
Reference in New Issue
Block a user