feat: introduce identity-backed goat
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user