feat: add deterministic goat routine

This commit is contained in:
Rijad Zuzo
2026-07-26 23:52:56 +02:00
parent f79c2bb630
commit e3aa3440a2
61 changed files with 1393 additions and 1057 deletions
+235
View File
@@ -0,0 +1,235 @@
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
var manager: Node = main_scene.get_node("SimulationManager")
manager.set_process(false)
for _frame in 3:
await physics_frame
var goat := main_scene.get_node("JajceWorld/WorldObjects/Animals/Dunja") as AnimalNode
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
var pasture := AnimalRoutineSite.get_by_id(SimulationIds.ANIMAL_SITE_DUNJA_PASTURE)
var shelter := AnimalRoutineSite.get_by_id(SimulationIds.ANIMAL_SITE_DUNJA_SHELTER)
_check(
goat != null and state != null and pasture != null and shelter != null,
"The routine proof needs Dunja and both loaded stable-ID habitat sites"
)
if goat == null or state == null or pasture == null or shelter == null:
_finish()
return
_check(
not state.begin_travel(shelter.site_id, shelter.get_routine_position()),
"Animal state should reject travel back to its current stable site",
)
state.set_enabled(false)
_check(
not state.begin_travel(pasture.site_id, pasture.get_routine_position()),
"Animal state should reject travel while the animal is disabled",
)
state.set_enabled(true)
for npc in manager.npcs:
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
npc.start_working()
var navigation_map: RID = main_scene.get_world_3d().navigation_map
await _wait_for_navigation_map(navigation_map)
NavigationServer3D.map_force_update(navigation_map)
var authored_path := NavigationServer3D.map_get_path(
navigation_map, shelter.get_routine_position(), pasture.get_routine_position(), true
)
_check(
not authored_path.is_empty(),
"The shelter and pasture should be connected by the real Jajce navigation map"
)
state.set_hunger(30.0)
_check(
state.schedule_routine_at(manager.tick_count),
"The test should be able to make the content goat's routine immediately due"
)
goat.move_speed = 4.0
manager.simulate_tick()
_check(
(
state.has_active_travel_target()
and state.get_routine_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_SHELTER
and state.get_travel_target_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE
and state.get_travel_target_position().is_equal_approx(pasture.get_routine_position())
),
"The due routine should deterministically choose pasture from shelter"
)
for _frame in 3:
await physics_frame
_check(
goat.is_navigating() and not goat.get_navigation_path().is_empty(),
"The loaded goat should follow a NavigationServer path rather than a straight state teleport"
)
var start_position := state.get_position()
for _frame in 22:
await physics_frame
var mid_route_position := state.get_position()
_check(
(
not mid_route_position.is_equal_approx(start_position)
and state.has_active_travel_target()
and goat.global_position.is_equal_approx(mid_route_position)
),
"Loaded movement should continuously report Dunja's authoritative mid-route position"
)
var mid_route_json: String = manager.serialize_state()
var mid_route_checksum: String = manager.get_state_checksum()
var random_streams_before_restore := JSON.stringify(
manager.create_state_record().simulation["wander_random_streams"]
)
var saved_target_position := state.get_travel_target_position()
_check(
manager.restore_state_from_json(mid_route_json),
"A schema-v11 save should accept Dunja's in-progress routine"
)
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(
(
manager.get_state_checksum() == mid_route_checksum
and state.get_position().is_equal_approx(mid_route_position)
and state.get_travel_target_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE
and state.get_travel_target_position().is_equal_approx(saved_target_position)
and goat.global_position.is_equal_approx(mid_route_position)
and (
JSON.stringify(manager.create_state_record().simulation["wander_random_streams"])
== random_streams_before_restore
)
),
"Mid-route restore should preserve exact position, target, checksum, and every NPC RNG stream"
)
var animals_parent := goat.get_parent()
goat.queue_free()
await process_frame
_check(
(
AnimalNode.get_by_id(SimulationIds.ANIMAL_DUNJA) == null
and state.has_active_travel_target()
and state.get_position().is_equal_approx(mid_route_position)
and manager.get_state_checksum() == mid_route_checksum
),
"Unloading Dunja's presentation should leave her in-progress simulation state untouched"
)
var reloaded_goat := load("res://world/animals/goat/cozy_goat.tscn").instantiate() as AnimalNode
animals_parent.add_child(reloaded_goat)
await process_frame
goat = reloaded_goat
goat.move_speed = 4.0
_check(
(
goat.state == state
and goat.global_position.is_equal_approx(mid_route_position)
and manager.get_state_checksum() == mid_route_checksum
),
"Reloading the self-contained goat scene should bind the same state without resetting it"
)
for _frame in 3:
await physics_frame
_check(
goat.is_navigating() and not goat.get_navigation_path().is_empty(),
"Restore should rebuild the loaded navigation path from saved facts"
)
for _frame in 240:
if not state.has_active_travel_target():
break
await physics_frame
_check(
(
not state.has_active_travel_target()
and state.get_routine_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE
and state.get_position().is_equal_approx(pasture.get_routine_position())
and not goat.is_navigating()
),
(
"Arrival should atomically promote pasture and retain its exact position "
+ (
"(active=%s site=%s state=%s target=%s navigating=%s)"
% [
state.has_active_travel_target(),
state.get_routine_site_id(),
state.get_position(),
pasture.get_routine_position(),
goat.is_navigating(),
]
)
)
)
var arrived_json: String = manager.serialize_state()
var arrived_checksum: String = manager.get_state_checksum()
_check(
(
manager.restore_state_from_json(arrived_json)
and manager.get_state_checksum() == arrived_checksum
),
"An arrived routine should also restore without replaying movement"
)
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(
not state.has_active_travel_target() and not goat.is_navigating(),
"An arrived save should remain quietly at pasture after binding"
)
state.schedule_routine_at(manager.tick_count)
manager.simulate_tick()
_check(
(
state.has_active_travel_target()
and state.get_travel_target_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_SHELTER
and state.get_travel_target_position().is_equal_approx(shelter.get_routine_position())
),
"The next due routine should deterministically alternate back to shelter"
)
state.set_hunger(AnimalStateRecord.FEED_THRESHOLD - AnimalStateRecord.HUNGER_PER_TICK)
manager.simulate_tick()
_check(
not state.has_active_travel_target() and not goat.is_navigating() and state.needs_feed(),
"Becoming hungry should stop routine travel so NPC and player feeding target a still goat"
)
_finish()
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 _finish() -> void:
if failures.is_empty():
print("[TEST] Animal pasture/shelter routine passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://wpfu3ft008sm
+1 -1
View File
@@ -105,7 +105,7 @@ func _run() -> void:
var active_restored := _create_manager(902)
_check(
active_restored.restore_state_from_json(active_json),
"An active opportunity should survive schema-v10 save and restore"
"An active opportunity should survive schema-v11 save and restore"
)
_check(
(
+1 -1
View File
@@ -269,7 +269,7 @@ func _run() -> void:
var old_contributor_visual := contributor_visual
_check(
simulation_manager.restore_state_from_json(active_need_json),
"The visible shortage should survive a valid schema-v10 restore"
"The visible shortage should survive a valid schema-v11 restore"
)
await process_frame
contributor = simulation_manager.npcs[contributor.id]
+27
View File
@@ -414,6 +414,31 @@ func _run() -> void:
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(
goat.is_in_group("grass_interactors"),
"Dunja should bend camera-local grass through the shared presentation group"
)
var habitat_root := world.get_node("WorldObjects/AnimalHabitats")
var habitat := habitat_root.get_node("DunjaHabitat")
var routine_sites: Array[AnimalRoutineSite] = []
for site in AnimalRoutineSite.get_all():
routine_sites.append(site)
routine_sites.sort_custom(
func(first: AnimalRoutineSite, second: AnimalRoutineSite) -> bool:
return String(first.site_id) < String(second.site_id)
)
_check(
habitat_root.get_child_count() == 1 and habitat != null and routine_sites.size() == 2,
"Jajce should keep Dunja's two routine anchors inside one self-contained habitat scene"
)
_check(
(
routine_sites.size() == 2
and routine_sites[0].site_id == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE
and routine_sites[1].site_id == SimulationIds.ANIMAL_SITE_DUNJA_SHELTER
),
"Dunja's pasture and shelter should expose deterministic stable IDs"
)
_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")
@@ -467,6 +492,8 @@ func _run() -> void:
for resource in resources:
destinations.append((resource as ResourceNode).interaction_point.global_position)
destinations.append(goat.get_interaction_position())
for site in routine_sites:
destinations.append(site.get_routine_position())
destinations.append(pantry.get_interaction_position())
destinations.append(woodpile.get_interaction_position())
for site in activity_root.get_children():
@@ -20,6 +20,7 @@ func _run() -> void:
_test_previous_world_retention_migration()
_test_previous_world_opportunity_migration()
_test_previous_world_animal_migration()
_test_previous_world_routine_migration()
_test_relationship_schema_rejection()
_test_opportunity_schema_rejection()
_test_animal_schema_rejection()
@@ -498,6 +499,41 @@ func _test_previous_world_animal_migration() -> void:
manager.free()
func _test_previous_world_routine_migration() -> void:
var manager := _create_manager(65)
var v10_data: Dictionary = manager.create_state_record().to_dictionary()
v10_data["schema_version"] = SimulationStateRecord.ROUTINE_LEGACY_SCHEMA_VERSION
v10_data["animals"] = [
{
"schema_version": AnimalStateRecord.LEGACY_SCHEMA_VERSION,
"animal_id": "legacy_goat",
"display_name": "Legacy",
"species_id": String(SimulationIds.SPECIES_GOAT),
"position": [-4.0, 0.0, -11.5],
"hunger": 40.0,
"last_fed_tick": AnimalStateRecord.NEVER_FED_TICK,
"reserved_by": -1,
"enabled": true,
"can_npcs_feed": true,
"can_player_feed": true,
}
]
var migrated := SimulationStateRecord.from_dictionary(v10_data)
_check(migrated != null, "World schema v10 should preserve and upgrade animal records")
if migrated != null:
var animal := migrated.animals[0]
_check(
(
animal.get_animal_id() == &"legacy_goat"
and animal.get_routine_site_id().is_empty()
and not animal.has_active_travel_target()
and animal.get_next_routine_tick() == 0
),
"World v10 migration should add deterministic idle routine defaults"
)
manager.free()
func _test_animal_schema_rejection() -> void:
var manager := _create_manager(64)
var missing_animals: Dictionary = manager.create_state_record().to_dictionary()
@@ -521,6 +557,11 @@ func _test_animal_schema_rejection() -> void:
"enabled": true,
"can_npcs_feed": true,
"can_player_feed": true,
"routine_site_id": "",
"travel_target_site_id": "",
"travel_target_position": [0.0, 0.0, 0.0],
"has_travel_target": false,
"next_routine_tick": 0,
}
)
)
+1 -1
View File
@@ -132,7 +132,7 @@ func _run() -> void:
var active_restored := _create_manager(1202)
_check(
active_restored.restore_state_from_json(active_json),
"An active missing-wood need should survive schema-v10 restore"
"An active missing-wood need should survive schema-v11 restore"
)
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
_check(