feat: introduce identity-backed goat

This commit is contained in:
Rijad Zuzo
2026-07-26 22:26:00 +02:00
parent 1f09ebe6b6
commit f79c2bb630
47 changed files with 1903 additions and 96 deletions
+232
View File
@@ -0,0 +1,232 @@
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 player := main_scene.get_node("Player") as Node3D
var pantry: StorageStateRecord = manager.get_pantry()
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(goat != null, "Jajce should load one named goat presentation")
_check(
AnimalNode.get_all().size() == 1 and ResourceNode.get_all().size() == 18,
"The goat should be one AnimalNode without changing the finite-resource count"
)
_check(
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
"Dunja must not masquerade as a ResourceNode",
)
_check(
(
goat.animal_id == SimulationIds.ANIMAL_DUNJA
and goat.display_name == "Dunja"
and goat.species_id == SimulationIds.SPECIES_GOAT
and state != null
and goat.state == state
),
"The loaded presentation should bind the stable named-goat identity"
)
if state == null:
_finish()
return
var authored_position := state.get_position()
_check(
goat.global_position.is_equal_approx(authored_position),
"Animal state should own the loaded presentation position"
)
state.set_position(authored_position + Vector3(0.25, 0.0, 0.0))
_check(
goat.global_position.is_equal_approx(state.get_position()),
"A simulation position change should move the bound goat presentation"
)
state.set_position(authored_position)
for index in range(1, manager.npcs.size()):
_park_npc(manager.npcs[index])
var caretaker: SimNPC = manager.npcs[0]
manager.release_npc_reservation(caretaker.id)
caretaker.inventory.clear()
caretaker.hunger = 20.0
caretaker.energy = 90.0
caretaker.current_task = SimulationIds.ACTION_IDLE
caretaker.task_state = SimNPC.TASK_STATE_IDLE
caretaker.task_complete = true
caretaker.target_id = &""
caretaker.has_travel_target = false
state.set_hunger(72.0)
var pantry_before_npc := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
manager.simulate_tick()
_check(
caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL,
"An available villager should autonomously choose to feed hungry Dunja"
)
if caretaker.target_id.is_empty():
manager.resolve_npc_target(caretaker.id, caretaker.position)
_check(
(
caretaker.target_id == SimulationIds.ANIMAL_DUNJA
and state.get_reserved_by() == caretaker.id
and caretaker.travel_target_position.is_equal_approx(goat.get_interaction_position())
),
"Animal target resolution should claim Dunja's loaded interaction point by stable ID"
)
manager.notify_npc_arrived(caretaker.id)
manager.simulate_tick()
var hunger_before_completion := state.get_hunger()
manager.simulate_tick()
_check(
is_equal_approx(
state.get_hunger(),
(
hunger_before_completion
+ AnimalStateRecord.HUNGER_PER_TICK
- AnimalStateRecord.HUNGER_RELIEF
)
),
"NPC feeding should apply the exact simulation-owned hunger relief"
)
_check(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before_npc - 1.0),
"NPC feeding should withdraw exactly one real pantry food"
)
_check(
state.get_last_fed_tick() == manager.tick_count and state.get_reserved_by() == -1,
"Completed feeding should retain its deterministic tick and release the claim"
)
var npc_feed_event := _latest_feed_event(manager)
_check(
(
npc_feed_event != null
and int(npc_feed_event.data["actor_id"]) == caretaker.id
and (
StringName(npc_feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
)
and (StringName(npc_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
and StringName(npc_feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(npc_feed_event.data["amount"]), 1.0)
and npc_feed_event.get_world_position().is_equal_approx(state.get_position())
),
"NPC feeding should record one exact pantry-to-animal transfer fact"
)
_check(
(
not goat.get_node("Visual/HungerCueRoot").visible
and goat.get_node("Visual/FedResponseRoot").visible
),
"Real feeding should clear the stable hunger cue and play one cozy response"
)
var npc_feed_json: String = manager.serialize_state()
var npc_feed_checksum: String = manager.get_state_checksum()
var saved_hunger := state.get_hunger()
var saved_fed_tick := state.get_last_fed_tick()
_check(
manager.restore_state_from_json(npc_feed_json),
"NPC-fed animal state should pass the complete schema and restore"
)
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
pantry = manager.get_pantry()
_check(
(
goat.state == state
and state.get_position().is_equal_approx(authored_position)
and is_equal_approx(state.get_hunger(), saved_hunger)
and state.get_last_fed_tick() == saved_fed_tick
and manager.get_state_checksum() == npc_feed_checksum
),
"Animal position, hunger, feed tick, and checksum should round-trip deterministically"
)
_check(
not goat.get_node("Visual/FedResponseRoot").visible,
"Restore should rebuild stable animal cues without replaying transient praise"
)
state.set_hunger(72.0)
var pantry_before_player := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
player.global_position = goat.get_interaction_position()
player.call("try_interact")
var player_feed_event := _latest_feed_event(manager)
_check(
(
is_equal_approx(
pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before_player - 1.0
)
and is_equal_approx(state.get_hunger(), 72.0 - AnimalStateRecord.HUNGER_RELIEF)
and player_feed_event != null
and int(player_feed_event.data["actor_id"]) == -1
and (StringName(player_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
),
"The player should use the same one-food feed operation and event contract"
)
var player_feed_json: String = manager.serialize_state()
_check(
manager.restore_state_from_json(player_feed_json),
"Player-fed animal history should restore through the same schema"
)
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
pantry = manager.get_pantry()
_check(
not goat.get_node("Visual/FedResponseRoot").visible,
"Player feed feedback should also remain transient across restore"
)
state.set_hunger(72.0)
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
var events_before_failed_feed: int = manager.economic_events.size()
player.global_position = goat.get_interaction_position()
player.call("try_interact")
_check(
(
is_equal_approx(state.get_hunger(), 72.0)
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and manager.economic_events.size() == events_before_failed_feed
),
"An empty pantry should block feeding without inventing food or animal relief"
)
_finish()
func _park_npc(npc: SimNPC) -> void:
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
npc.start_working()
func _latest_feed_event(manager: Node) -> EconomicEventRecord:
for index in range(manager.economic_events.size() - 1, -1, -1):
var event := manager.economic_events[index] as EconomicEventRecord
if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
return event
return null
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Animal feeding vertical slice passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://cgnst4m7bp3tb
+2 -2
View File
@@ -44,8 +44,8 @@ func _run_scenario(seed_value: int) -> String:
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
manager.notify_npc_arrived(npc.id)
var snapshot: Dictionary = manager.get_state_snapshot()
snapshot.erase("seed")
var snapshot: Dictionary = manager.create_state_record().to_dictionary()
snapshot["simulation"].erase("seed")
var checksum := JSON.stringify(snapshot).sha256_text()
manager.free()
return checksum
+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-v9 save and restore"
"An active opportunity should survive schema-v10 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-v9 restore"
"The visible shortage should survive a valid schema-v10 restore"
)
await process_frame
contributor = simulation_manager.npcs[contributor.id]
+19
View File
@@ -396,6 +396,24 @@ func _run() -> void:
)
_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")
@@ -448,6 +466,7 @@ func _run() -> void:
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():
+11 -1
View File
@@ -35,7 +35,7 @@ func _test_registry_integrity() -> void:
"Action lookup should return '%s'" % definition.action_id
)
_check(
action_ids.size() == 11, "Registry should contain all eleven executable prototype actions"
action_ids.size() == 12, "Registry should contain all twelve executable prototype actions"
)
var profession_ids := SimulationDefinitions.get_profession_ids()
@@ -88,6 +88,16 @@ func _test_definition_backed_behavior() -> void:
),
"Gather-food target metadata should come from its definition"
)
var feed := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
_check(
(
feed.target_type == SimulationIds.TARGET_ANIMAL
and feed.preferred_profession_id == SimulationIds.PROFESSION_FARMER
and feed.completion_cost_resource_id == SimulationIds.RESOURCE_FOOD
and is_equal_approx(feed.completion_cost_amount, 1.0)
),
"Feed-animal should target an animal and declare its exact pantry-food cost"
)
func _test_state_reference_validation() -> void:
@@ -19,8 +19,10 @@ func _run() -> void:
_test_previous_world_provenance_migration()
_test_previous_world_retention_migration()
_test_previous_world_opportunity_migration()
_test_previous_world_animal_migration()
_test_relationship_schema_rejection()
_test_opportunity_schema_rejection()
_test_animal_schema_rejection()
_test_schema_rejection()
if failures.is_empty():
@@ -481,6 +483,51 @@ func _test_previous_world_opportunity_migration() -> void:
manager.free()
func _test_previous_world_animal_migration() -> void:
var manager := _create_manager(63)
var v9_data: Dictionary = _build_opportunity_world(manager, false)
v9_data["schema_version"] = SimulationStateRecord.ANIMAL_LEGACY_SCHEMA_VERSION
v9_data.erase("animals")
var migrated := SimulationStateRecord.from_dictionary(v9_data)
_check(migrated != null, "World schema v9 should add an empty animal stream")
if migrated != null:
_check(
migrated.animals.is_empty() and migrated.opportunities.size() == 1,
"World v9 migration should preserve opportunity history while adding animals"
)
manager.free()
func _test_animal_schema_rejection() -> void:
var manager := _create_manager(64)
var missing_animals: Dictionary = manager.create_state_record().to_dictionary()
missing_animals.erase("animals")
_check(
SimulationStateRecord.from_dictionary(missing_animals) == null,
"Current world records should require the authoritative animal array"
)
var invalid_position := (
AnimalStateRecord
. from_dictionary(
{
"schema_version": AnimalStateRecord.SCHEMA_VERSION,
"animal_id": "invalid_goat",
"display_name": "Invalid",
"species_id": String(SimulationIds.SPECIES_GOAT),
"position": [NAN, 0.0, 0.0],
"hunger": 70.0,
"last_fed_tick": AnimalStateRecord.NEVER_FED_TICK,
"reserved_by": -1,
"enabled": true,
"can_npcs_feed": true,
"can_player_feed": true,
}
)
)
_check(invalid_position == null, "Animal records should reject non-finite positions")
manager.free()
func _test_relationship_schema_rejection() -> void:
var manager := _create_manager(58)
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
+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-v9 restore"
"An active missing-wood need should survive schema-v10 restore"
)
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
_check(