feat: add knowledge-gated pantry opportunities

This commit is contained in:
Rijad Zuzo
2026-07-13 00:46:56 +02:00
parent 9fcf6a0a58
commit c722e32a88
26 changed files with 2027 additions and 77 deletions
+369
View File
@@ -0,0 +1,369 @@
extends SceneTree
var failures: Array[String] = []
var opened_ids: Array[int] = []
var resolved_ids: Array[int] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
manager.opportunity_opened.connect(_on_opportunity_opened)
manager.opportunity_resolved.connect(_on_opportunity_resolved)
var actor: SimNPC = manager.npcs[0]
var witness: SimNPC = manager.npcs[1]
var interested: SimNPC = manager.npcs[2]
var supplier: SimNPC = manager.npcs[3]
var pantry: StorageStateRecord = manager.get_pantry()
_set_pantry_amount(manager, 1.0)
actor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
interested.position = Vector3(20.0, 0.0, 0.0)
supplier.position = Vector3(40.0, 0.0, 0.0)
actor.hunger = 60.0
witness.hunger = 20.0
interested.hunger = 85.0
_check(
manager.get_active_opportunity() == null,
"An empty-or-low pantry alone must not invent an opportunity"
)
_check(
is_equal_approx(
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0), 1.0
),
"The opportunity trigger should be a real last-food withdrawal"
)
var trigger_event := _latest_event_of_type(
manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, actor.id
)
_check(trigger_event != null, "The last-food withdrawal should exist in objective history")
if trigger_event == null:
manager.free()
_finish()
return
var trigger_event_id := int(trigger_event.data["event_id"])
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and manager.npc_knows_event(actor.id, trigger_event_id)
and manager.npc_knows_event(witness.id, trigger_event_id)
and not manager.npc_knows_event(interested.id, trigger_event_id)
and manager.get_active_opportunity() == null
),
"Knowledge without critical hunger and critical hunger without knowledge should not open"
)
_prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0))
var event_count_before_open: int = manager.economic_events.size()
_check(
manager.try_communicate_at_shared_activity(actor.id, interested.id),
"A direct knower should be able to report the empty pantry at shared work"
)
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
_check(opportunity != null, "A critically hungry knower should open the village need")
if opportunity == null:
manager.free()
_finish()
return
_check(
(
opportunity.get_opportunity_id() == 0
and opportunity.get_trigger_event_id() == trigger_event_id
and opportunity.get_interested_npc_id() == interested.id
and opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY
and opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD
and is_equal_approx(opportunity.get_target_amount(), 1.0)
and opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN
),
"The open opportunity should reference the real knower, trigger, storage, and goal"
)
_check(
(
opened_ids == [opportunity.get_opportunity_id()]
and manager.economic_events.size() == event_count_before_open
and manager.opportunity_system.next_opportunity_id == 1
),
"Opening should emit once without adding quest-only objective events"
)
_check(
manager.is_known_event_lasting(interested.id, trigger_event_id),
"An unresolved village need should protect its interested villager's causal memory"
)
_check(
(
not manager.try_communicate_at_shared_activity(actor.id, interested.id)
and manager.opportunity_system.get_all_sorted().size() == 1
),
"Repeated reports should neither replace provenance nor duplicate an open need"
)
var active_json: String = manager.serialize_state()
var active_restored := _create_manager(902)
_check(
active_restored.restore_state_from_json(active_json),
"An active opportunity should survive schema-v8 save and restore"
)
_check(
(
active_restored.get_state_checksum() == manager.get_state_checksum()
and active_restored.get_active_opportunity() != null
and active_restored.is_known_event_lasting(interested.id, trigger_event_id)
),
"Active restore should preserve checksum, stable identity, and protected evidence"
)
active_restored.tick_count = active_restored.get_knowledge_review_interval()
active_restored.call("_maintain_event_knowledge", true)
_check(
active_restored.npc_knows_event(interested.id, trigger_event_id),
"Daily memory review must retain the evidence for an unresolved opportunity"
)
var restored_supplier: SimNPC = active_restored.npcs[3]
restored_supplier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
_check(
is_equal_approx(
active_restored.economy.deposit_inventory(
restored_supplier, SimulationIds.RESOURCE_FOOD
),
1.0
),
"A restored opportunity should still accept a real pantry supply event"
)
var restored_resolution: OpportunityStateRecord = (
active_restored.get_latest_opportunity_for_npc(interested.id)
)
_check(
(
restored_resolution.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and active_restored.get_active_opportunity() == null
and (
(
active_restored
. get_opportunity_resolution_event(restored_resolution)
. data["event_id"]
)
== restored_resolution.get_resolution_event_id()
)
),
"Restored wiring should close exactly once against the new persisted event ID"
)
active_restored.free()
interested.position = Vector3(21.0, 0.0, 0.0)
supplier.position = Vector3(22.0, 0.0, 0.0)
supplier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
var event_count_before_resolution: int = manager.economic_events.size()
_check(
is_equal_approx(
manager.economy.deposit_inventory(supplier, SimulationIds.RESOURCE_FOOD), 1.0
),
"An NPC should resolve the need through the real inventory-to-pantry transaction"
)
var resolved: OpportunityStateRecord = manager.get_latest_opportunity_for_npc(interested.id)
var resolution_event: EconomicEventRecord = manager.get_opportunity_resolution_event(resolved)
_check(
(
resolved != null
and resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and manager.get_active_opportunity() == null
and resolution_event != null
and (
StringName(resolution_event.data["event_type"])
== SimulationIds.EVENT_STORAGE_DEPOSITED
)
and resolved.get_resolution_event_id() == int(resolution_event.data["event_id"])
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 1.0)
),
"The opportunity should close on the exact later NPC supply event"
)
_check(
(
resolved_ids == [opportunity.get_opportunity_id()]
and manager.economic_events.size() == event_count_before_resolution + 1
),
"Resolution should emit once and reuse the supply event instead of creating quest history"
)
_check(
not manager.is_known_event_lasting(interested.id, trigger_event_id),
"Resolved needs should release their trigger evidence back to normal memory retention"
)
var supplier_relationship: RelationshipStateRecord = (
manager.relationship_system.get_relationship(interested.id, supplier.id)
)
_check(
(
supplier_relationship != null
and is_equal_approx(supplier_relationship.get_trust(), 0.65)
and (
supplier_relationship.get_last_trust_cause_event_id()
== resolved.get_resolution_event_id()
)
),
"The real resolving deposit should retain its existing witnessed trust consequence"
)
var resolved_json: String = manager.serialize_state()
var resolved_restored := _create_manager(903)
_check(
resolved_restored.restore_state_from_json(resolved_json),
"Resolved opportunity history should restore"
)
_check(
(
resolved_restored.get_state_checksum() == manager.get_state_checksum()
and resolved_restored.get_active_opportunity() == null
and (
resolved_restored.get_latest_opportunity_for_npc(interested.id).get_status()
== OpportunityStateRecord.STATUS_RESOLVED
)
),
"Resolved restore should preserve checksum without reopening the old shortage"
)
resolved_restored.free()
manager.free()
opened_ids.clear()
resolved_ids.clear()
var player_manager := _create_manager(904)
var player_setup := _open_via_communication(player_manager)
var player_interested: SimNPC = player_setup["interested"]
var berry := ResourceNode.new()
berry.name = "OpportunityBerryBush"
berry.node_id = &"opportunity_berry_bush"
berry.action_id = SimulationIds.ACTION_GATHER_FOOD
berry.resource_id = SimulationIds.RESOURCE_FOOD
berry.initial_amount = 1.0
berry.yield_per_action = 1.0
berry.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
berry.add_child(interaction_point)
root.add_child(berry)
await process_frame
_check(
player_manager.register_resource_node(berry),
"The player resolution branch should bind a real finite ResourceNode"
)
_check(
is_equal_approx(player_manager.harvest_resource_node(berry), 1.0),
"Player harvesting should supply the real pantry through the existing command"
)
var player_resolved: OpportunityStateRecord = player_manager.get_latest_opportunity_for_npc(
player_interested.id
)
var player_resolution: EconomicEventRecord = player_manager.get_opportunity_resolution_event(
player_resolved
)
_check(
(
player_resolved != null
and player_resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and player_resolution != null
and (
StringName(player_resolution.data["event_type"])
== SimulationIds.EVENT_RESOURCE_EXTRACTED
)
and int(player_resolution.data["actor_id"]) == -1
and (
StringName(player_resolution.data["destination_id"])
== SimulationIds.STORAGE_VILLAGE_PANTRY
)
),
"Player harvesting should be the second real resolution path with exact history"
)
berry.queue_free()
player_manager.free()
_finish()
func _open_via_communication(manager: Node) -> Dictionary:
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
_prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0))
_check(
manager.try_communicate_at_shared_activity(actor.id, interested.id),
"Player branch should open from the same one-hop known shortage"
)
_check(manager.get_active_opportunity() != null, "Player branch needs an active opportunity")
return {"actor": actor, "interested": interested}
func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void:
for npc in [first, second]:
npc.set_task(SimulationIds.ACTION_PATROL)
npc.target_id = &"guard_post"
npc.start_working()
first.position = position
second.position = position + Vector3(1.0, 0.0, 0.0)
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _latest_event_of_type(
manager: Node, event_type: StringName, actor_id: int
) -> EconomicEventRecord:
var events: Array = manager.get_npc_events(actor_id, 8)
for index in range(events.size() - 1, -1, -1):
var event := events[index] as EconomicEventRecord
if StringName(event.data["event_type"]) == event_type:
return event
return null
func _create_manager(seed_value: int = 901) -> 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(2.0, 0.0, 0.0),
Vector3(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.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 _on_opportunity_opened(opportunity: OpportunityStateRecord) -> void:
opened_ids.append(opportunity.get_opportunity_id())
func _on_opportunity_resolved(
opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord
) -> void:
resolved_ids.append(opportunity.get_opportunity_id())
func _finish() -> void:
if failures.is_empty():
print("[TEST] Food shortage opportunity passed: known need -> real supply")
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://c42mc37eiqjrw
+99
View File
@@ -119,6 +119,105 @@ func _run() -> void:
not listener_visual.get_node("TrustReactionRoot").visible,
"Learning a fact without a relationship consequence should not show a trust blossom"
)
var village_stats_label := (
main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label
)
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
contributor.hunger = 85.0
var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory(
contributor,
SimulationIds.RESOURCE_FOOD,
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
)
_check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry")
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
_check(opportunity != null, "Emptying the pantry should open one known shortage need")
_check(
(
"Village need" in village_stats_label.text
and "◆ Restock the empty pantry" in village_stats_label.text
and (
"%s is worried about the shortage" % contributor.npc_name
in village_stats_label.text
)
and "Food 0 / 1" in village_stats_label.text
),
"An open opportunity should add one compact state-derived village need"
)
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Open village need" in inspector_label.text
and "◆ Restock the empty pantry" in inspector_label.text
and "Food 0 / 1" in inspector_label.text
),
"The interested villager should expose the open pantry need in their inspector"
)
village_ui.selected_npc_index = listener.id
village_ui.call("_refresh_npc_inspector")
_check(
"Open village need" not in inspector_label.text,
"An unrelated selected villager should not inherit someone else's open need"
)
_check(
(
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD)
> 0.0
),
"Opportunity presentation should resolve from a real pantry deposit"
)
_check(
"Village need" not in village_stats_label.text,
"Resolved opportunities should leave the compact village summary"
)
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Resolved village need" in inspector_label.text
and "%s restocked the pantry" % contributor.npc_name in inspector_label.text
and "Food target 1" in inspector_label.text
),
"The interested villager should retain a resolved detail naming the real actor"
)
village_ui.selected_npc_index = listener.id
village_ui.call("_refresh_npc_inspector")
_check(
"Resolved village need" not in inspector_label.text,
"Resolved opportunity detail should remain scoped to its interested villager"
)
var player_need_withdrawal: float = simulation_manager.economy.withdraw_to_inventory(
contributor,
SimulationIds.RESOURCE_FOOD,
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
)
_check(player_need_withdrawal > 0.0, "Player resolution setup should reopen a real need")
var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
_check(player_opportunity != null, "A later shortage should open a new opportunity record")
var player := main_scene.get_node("Player") as Node3D
var opportunity_bush := (
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
)
var opportunity_bush_state: ResourceStateRecord = simulation_manager.get_resource_state(
opportunity_bush.node_id
)
opportunity_bush_state.set_amount_remaining(1.0)
player.global_position = opportunity_bush.interaction_point.global_position
player.call("try_interact")
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and "Resolved village need" in inspector_label.text
and "◆ Player restocked the pantry" in inspector_label.text
),
"Player gathering should resolve the same need and be named from its real event"
)
_check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"Playable runtime should instance the Jajce Terrain3D world"
+265 -1
View File
@@ -18,7 +18,9 @@ func _run() -> void:
_test_previous_world_knowledge_migration()
_test_previous_world_provenance_migration()
_test_previous_world_retention_migration()
_test_previous_world_opportunity_migration()
_test_relationship_schema_rejection()
_test_opportunity_schema_rejection()
_test_schema_rejection()
if failures.is_empty():
@@ -371,7 +373,7 @@ func _test_previous_world_provenance_migration() -> void:
func _test_previous_world_retention_migration() -> void:
var manager := _create_manager(61)
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
previous_data["schema_version"] = SimulationStateRecord.RETENTION_LEGACY_SCHEMA_VERSION
previous_data["simulation"]["tick_count"] = 50
var deposit_event := EconomicEventRecord.create(
0,
@@ -439,6 +441,25 @@ func _test_previous_world_retention_migration() -> void:
manager.free()
func _test_previous_world_opportunity_migration() -> void:
var manager := _create_manager(62)
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
previous_data.erase("opportunities")
previous_data["simulation"].erase("next_opportunity_id")
var migrated := SimulationStateRecord.from_dictionary(previous_data)
_check(migrated != null, "World schema v7 should add an empty opportunity stream")
if migrated != null:
_check(
(
migrated.opportunities.is_empty()
and int(migrated.simulation["next_opportunity_id"]) == 0
),
"World v7 migration should initialize deterministic opportunity identity"
)
manager.free()
func _test_relationship_schema_rejection() -> void:
var manager := _create_manager(58)
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
@@ -685,6 +706,249 @@ func _test_relationship_schema_rejection() -> void:
manager.free()
func _test_opportunity_schema_rejection() -> void:
var manager := _create_manager(63)
var valid_open := _build_opportunity_world(manager, false)
_check(
SimulationStateRecord.from_dictionary(valid_open) != null,
"A known empty-pantry trigger should accept one open restock opportunity"
)
var missing_knowledge := valid_open.duplicate(true)
missing_knowledge["event_knowledge"] = []
_check(
SimulationStateRecord.from_dictionary(missing_knowledge) == null,
"An open opportunity should require its interested NPC to know the trigger"
)
var knowledge_acquired_too_late := valid_open.duplicate(true)
knowledge_acquired_too_late["event_knowledge"][0]["acquisition_method"] = String(
SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY
)
knowledge_acquired_too_late["event_knowledge"][0]["acquired_tick"] = 3
_check(
SimulationStateRecord.from_dictionary(knowledge_acquired_too_late) == null,
"An open opportunity cannot predate the interested NPC's knowledge"
)
var already_supplied := valid_open.duplicate(true)
for storage_data in already_supplied["storages"]:
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 1.0
_check(
SimulationStateRecord.from_dictionary(already_supplied) == null,
"An open pantry need should reject a save whose target is already supplied"
)
var duplicate_id := valid_open.duplicate(true)
var second_opportunity: Dictionary = duplicate_id["opportunities"][0].duplicate(true)
second_opportunity["trigger_event_id"] = 1
duplicate_id["economic_events"].append(_create_pantry_withdrawal_event(1, 3, 1).to_dictionary())
duplicate_id["event_knowledge"].append(
(
KnownEventStateRecord
. create(
1,
1,
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
KnownEventStateRecord.NO_SOURCE_NPC_ID,
3
)
. to_dictionary()
)
)
duplicate_id["opportunities"].append(second_opportunity)
duplicate_id["simulation"]["next_event_id"] = 2
duplicate_id["simulation"]["next_opportunity_id"] = 2
_check(
SimulationStateRecord.from_dictionary(duplicate_id) == null,
"Opportunity IDs should be unique"
)
var multiple_open := duplicate_id.duplicate(true)
multiple_open["opportunities"][1]["opportunity_id"] = 1
_check(
SimulationStateRecord.from_dictionary(multiple_open) == null,
"The bounded pantry slice should restore at most one open opportunity"
)
var duplicate_trigger := valid_open.duplicate(true)
var repeated_trigger: Dictionary = duplicate_trigger["opportunities"][0].duplicate(true)
repeated_trigger["opportunity_id"] = 1
duplicate_trigger["opportunities"].append(repeated_trigger)
duplicate_trigger["simulation"]["next_opportunity_id"] = 2
_check(
SimulationStateRecord.from_dictionary(duplicate_trigger) == null,
"One objective trigger should not create duplicate opportunity records"
)
var colliding_next_id := valid_open.duplicate(true)
colliding_next_id["simulation"]["next_opportunity_id"] = 0
_check(
SimulationStateRecord.from_dictionary(colliding_next_id) == null,
"The next opportunity ID should remain above restored history"
)
var unknown_interested_npc := valid_open.duplicate(true)
unknown_interested_npc["opportunities"][0]["interested_npc_id"] = 999
_check(
SimulationStateRecord.from_dictionary(unknown_interested_npc) == null,
"Opportunities should reference an existing interested NPC"
)
var future_creation := valid_open.duplicate(true)
future_creation["opportunities"][0]["created_tick"] = 11
_check(
SimulationStateRecord.from_dictionary(future_creation) == null,
"Opportunity creation cannot occur after the saved simulation tick"
)
var before_trigger := valid_open.duplicate(true)
before_trigger["opportunities"][0]["created_tick"] = 1
_check(
SimulationStateRecord.from_dictionary(before_trigger) == null,
"Opportunity creation cannot predate its trigger event"
)
var invalid_trigger := valid_open.duplicate(true)
invalid_trigger["economic_events"][0]["destination_id"] = "unrelated_inventory"
_check(
SimulationStateRecord.from_dictionary(invalid_trigger) == null,
"A restock opportunity trigger should be a real NPC pantry withdrawal"
)
var player_trigger := valid_open.duplicate(true)
player_trigger["economic_events"][0]["actor_id"] = -1
player_trigger["economic_events"][0]["destination_id"] = "player_inventory"
_check(
SimulationStateRecord.from_dictionary(player_trigger) == null,
"Player extraction or consumption should not masquerade as an informed NPC trigger"
)
var valid_resolved := _build_opportunity_world(manager, true)
_check(
SimulationStateRecord.from_dictionary(valid_resolved) != null,
"A later NPC pantry deposit should resolve an opportunity without retained knowledge"
)
var player_resolved := _build_opportunity_world(manager, true)
player_resolved["economic_events"][1] = (
EconomicEventRecord
. create(
1,
SimulationIds.EVENT_RESOURCE_EXTRACTED,
3,
-1,
&"schema_test_berry",
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
. to_dictionary()
)
player_resolved["resources"].append(
_create_player_food_resource_state(&"schema_test_berry").to_dictionary()
)
_check(
SimulationStateRecord.from_dictionary(player_resolved) != null,
"A later player resource extraction into the pantry should be a valid resolution"
)
var unknown_player_source := player_resolved.duplicate(true)
unknown_player_source["economic_events"][1]["source_id"] = "missing_berry"
_check(
SimulationStateRecord.from_dictionary(unknown_player_source) == null,
"A player resolution should reference a real player-usable food resource"
)
var invalid_resolution := valid_resolved.duplicate(true)
invalid_resolution["economic_events"][1]["destination_id"] = "somewhere_else"
_check(
SimulationStateRecord.from_dictionary(invalid_resolution) == null,
"Resolution events should actually supply food to the opportunity target"
)
var mismatched_resolution_tick := valid_resolved.duplicate(true)
mismatched_resolution_tick["opportunities"][0]["resolved_tick"] = 4
_check(
SimulationStateRecord.from_dictionary(mismatched_resolution_tick) == null,
"Resolved opportunity ticks should match the referenced resolution event"
)
manager.free()
func _build_opportunity_world(manager: Node, resolved: bool) -> Dictionary:
var world: Dictionary = manager.create_state_record().to_dictionary()
world["simulation"]["tick_count"] = 10
for storage_data in world["storages"]:
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = (
1.0 if resolved else 0.0
)
world["economic_events"] = [_create_pantry_withdrawal_event(0, 2, 0).to_dictionary()]
world["simulation"]["next_event_id"] = 1
var opportunity := OpportunityStateRecord.create(0, 2, 0, 0)
if resolved:
world["economic_events"].append(
(
EconomicEventRecord
. create(
1,
SimulationIds.EVENT_STORAGE_DEPOSITED,
3,
1,
SimulationIds.npc_inventory_id(1),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
. to_dictionary()
)
)
world["simulation"]["next_event_id"] = 2
opportunity.resolve(1, 3)
world["event_knowledge"] = []
else:
world["event_knowledge"] = [
(
KnownEventStateRecord
. create(
0,
0,
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
KnownEventStateRecord.NO_SOURCE_NPC_ID,
2
)
. to_dictionary()
)
]
world["opportunities"] = [opportunity.to_dictionary()]
world["simulation"]["next_opportunity_id"] = 1
return world
func _create_pantry_withdrawal_event(
event_id: int, tick: int, actor_id: int
) -> EconomicEventRecord:
return EconomicEventRecord.create(
event_id,
SimulationIds.EVENT_STORAGE_WITHDRAWN,
tick,
actor_id,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.npc_inventory_id(actor_id),
SimulationIds.RESOURCE_FOOD,
1.0
)
func _create_player_food_resource_state(node_id: StringName) -> ResourceStateRecord:
var resource_node := ResourceNode.new()
resource_node.node_id = node_id
resource_node.action_id = SimulationIds.ACTION_GATHER_FOOD
resource_node.resource_id = SimulationIds.RESOURCE_FOOD
resource_node.can_player_use = true
var resource_state := ResourceStateRecord.create_from_node(resource_node)
resource_node.free()
return resource_state
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
@@ -0,0 +1,316 @@
extends GutTest
const FoodShortageOpportunitySystemScript := preload(
"res://simulation/opportunities/FoodShortageOpportunitySystem.gd"
)
func test_opening_requires_current_knowledge_and_care() -> void:
var system := FoodShortageOpportunitySystemScript.new()
var pantry := _empty_pantry()
var npc := _npc(0, 90.0)
var npcs: Array[SimNPC] = [npc]
var knowledge := EventKnowledgeSystem.new()
var trigger := _withdrawal_event(4, 8, npc.id)
assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {}))
_remember(knowledge, npc.id, 4, 8)
npc.hunger = FoodShortageOpportunitySystem.CARE_HUNGER_THRESHOLD - 0.01
assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {}))
assert_true(system.get_all_sorted().is_empty())
assert_eq(system.next_opportunity_id, 0)
func test_highest_hunger_then_lowest_id_deterministically_owns_the_opportunity() -> void:
var system := FoodShortageOpportunitySystemScript.new()
var pantry := _empty_pantry()
var lower_hunger := _npc(1, 85.0)
var high_id := _npc(5, 92.0)
var low_id := _npc(2, 92.0)
var npcs: Array[SimNPC] = [high_id, lower_hunger, low_id]
var knowledge := EventKnowledgeSystem.new()
var trigger := _withdrawal_event(7, 10, lower_hunger.id)
for npc in npcs:
_remember(knowledge, npc.id, 7, 10)
var pantry_before := pantry.to_dictionary()
var tasks_before: Array[StringName] = []
for npc in npcs:
tasks_before.append(npc.current_task)
var opened: OpportunityStateRecord = system.consider_event(
trigger, 10, pantry, npcs, knowledge, {}
)
assert_not_null(opened)
assert_eq(opened.get_opportunity_id(), 0)
assert_eq(opened.get_interested_npc_id(), low_id.id)
assert_eq(opened.get_trigger_event_id(), 7)
assert_eq(opened.get_created_tick(), 10)
assert_eq(opened.get_opportunity_type(), SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY)
assert_eq(opened.get_status(), OpportunityStateRecord.STATUS_OPEN)
assert_eq(opened.get_target_id(), SimulationIds.STORAGE_VILLAGE_PANTRY)
assert_eq(opened.get_resource_id(), SimulationIds.RESOURCE_FOOD)
assert_eq(opened.get_target_amount(), 1.0)
assert_eq(pantry.to_dictionary(), pantry_before)
for index in npcs.size():
assert_eq(npcs[index].current_task, tasks_before[index])
assert_eq(system.next_opportunity_id, 1)
func test_near_equal_hunger_still_uses_the_exact_highest_value() -> void:
var system := FoodShortageOpportunitySystemScript.new()
var pantry := _empty_pantry()
var lower_id := _npc(1, 92.0)
var slightly_hungrier := _npc(9, 92.000001)
var npcs: Array[SimNPC] = [lower_id, slightly_hungrier]
var knowledge := EventKnowledgeSystem.new()
var trigger := _withdrawal_event(8, 10, lower_id.id)
for npc in npcs:
_remember(knowledge, npc.id, 8, 10)
var opened: OpportunityStateRecord = system.consider_event(
trigger, 10, pantry, npcs, knowledge, {}
)
assert_not_null(opened)
assert_eq(opened.get_interested_npc_id(), slightly_hungrier.id)
func test_late_knowledge_can_open_old_event_and_open_opportunity_dedupes() -> void:
var system := FoodShortageOpportunitySystemScript.new()
var pantry := _empty_pantry()
var npc := _npc(3, 88.0)
var npcs: Array[SimNPC] = [npc]
var knowledge := EventKnowledgeSystem.new()
var original_trigger := _withdrawal_event(10, 4, npc.id)
assert_null(system.consider_event(original_trigger, 4, pantry, npcs, knowledge, {}))
_remember(knowledge, npc.id, 10, 12)
var opened: OpportunityStateRecord = system.consider_event(
original_trigger, 12, pantry, npcs, knowledge, {}
)
assert_not_null(opened)
assert_eq(opened.get_created_tick(), 12)
var duplicate_trigger := _withdrawal_event(11, 13, npc.id)
_remember(knowledge, npc.id, 11, 13)
assert_null(system.consider_event(duplicate_trigger, 13, pantry, npcs, knowledge, {}))
assert_eq(system.get_all_sorted().size(), 1)
assert_eq(system.next_opportunity_id, 1)
func test_only_strict_later_npc_deposit_resolves_without_mutating_supply() -> void:
var fixture := _open_fixture()
var system: FoodShortageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"]
var npcs: Array[SimNPC] = fixture["npcs"]
var knowledge: EventKnowledgeSystem = fixture["knowledge"]
pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0)
var food_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
var invalid_events: Array[EconomicEventRecord] = [
_supply_event(20, 11, SimulationIds.EVENT_RESOURCE_EXTRACTED, 0),
_supply_event(21, 11, SimulationIds.EVENT_STORAGE_DEPOSITED, -1),
EconomicEventRecord.create(
25,
SimulationIds.EVENT_STORAGE_DEPOSITED,
11,
0,
&"not_the_actor_inventory",
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
),
EconomicEventRecord.create(
22,
SimulationIds.EVENT_STORAGE_DEPOSITED,
11,
0,
SimulationIds.npc_inventory_id(0),
SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_FOOD,
1.0
),
EconomicEventRecord.create(
23,
SimulationIds.EVENT_STORAGE_DEPOSITED,
11,
0,
SimulationIds.npc_inventory_id(0),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
0.0
),
]
for event in invalid_events:
assert_null(system.consider_event(event, 11, pantry, npcs, knowledge, {}))
assert_eq(pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before)
assert_eq(system.get_open_opportunity().get_status(), OpportunityStateRecord.STATUS_OPEN)
var valid_supply := _supply_event(24, 12, SimulationIds.EVENT_STORAGE_DEPOSITED, 0)
var resolved: OpportunityStateRecord = system.consider_event(
valid_supply, 12, pantry, npcs, knowledge, {}
)
assert_not_null(resolved)
assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED)
assert_eq(resolved.get_resolution_event_id(), 24)
assert_eq(resolved.get_resolved_tick(), 12)
assert_null(system.get_open_opportunity())
assert_eq(pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before)
func test_player_extraction_into_pantry_is_the_other_valid_resolution() -> void:
var fixture := _open_fixture()
var system: FoodShortageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"]
var npcs: Array[SimNPC] = fixture["npcs"]
var knowledge: EventKnowledgeSystem = fixture["knowledge"]
pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0)
var player_supply := _supply_event(30, 14, SimulationIds.EVENT_RESOURCE_EXTRACTED, -1)
assert_null(system.consider_event(player_supply, 14, pantry, npcs, knowledge, {}))
assert_not_null(system.get_open_opportunity())
var resolved: OpportunityStateRecord = system.consider_event(
player_supply, 14, pantry, npcs, knowledge, _player_resource_states()
)
assert_not_null(resolved)
assert_eq(resolved.get_resolution_event_id(), 30)
assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED)
func test_restore_preserves_ids_sorted_queries_and_resolves_only_oldest_open() -> void:
var older := OpportunityStateRecord.create(2, 5, 4, 7)
var newer := OpportunityStateRecord.create(5, 8, 7, 9)
var restored_records: Array[OpportunityStateRecord] = [newer, older]
var system := FoodShortageOpportunitySystemScript.new()
system.restore(restored_records, 6)
assert_eq(system.next_opportunity_id, 6)
assert_eq(system.get_all_sorted().map(_opportunity_id), [2, 5])
assert_eq(system.get_open_opportunity(), older)
assert_eq(system.get_latest_for_npc(9), newer)
assert_null(system.get_latest_for_npc(100))
var pantry := _empty_pantry()
pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0)
var npcs: Array[SimNPC] = [_npc(0, 10.0)]
var supply := _supply_event(40, 10, SimulationIds.EVENT_STORAGE_DEPOSITED, 0)
var resolved: OpportunityStateRecord = system.consider_event(
supply, 10, pantry, npcs, EventKnowledgeSystem.new(), {}
)
assert_eq(resolved, older)
assert_eq(older.get_status(), OpportunityStateRecord.STATUS_RESOLVED)
assert_eq(newer.get_status(), OpportunityStateRecord.STATUS_OPEN)
assert_eq(system.get_open_opportunity(), newer)
func test_record_parser_rejects_malformed_state_combinations() -> void:
var valid := OpportunityStateRecord.create(3, 10, 8, 2)
assert_not_null(OpportunityStateRecord.from_dictionary(valid.to_dictionary()))
var invalid_open := valid.to_dictionary()
invalid_open["resolution_event_id"] = 9
assert_null(OpportunityStateRecord.from_dictionary(invalid_open))
var invalid_type := valid.to_dictionary()
invalid_type["opportunity_type"] = "generic_quest"
assert_null(OpportunityStateRecord.from_dictionary(invalid_type))
var invalid_target := valid.to_dictionary()
invalid_target["target_amount"] = 2.0
assert_null(OpportunityStateRecord.from_dictionary(invalid_target))
var invalid_resolved := valid.to_dictionary()
invalid_resolved["status"] = String(OpportunityStateRecord.STATUS_RESOLVED)
invalid_resolved["resolution_event_id"] = 12
invalid_resolved["resolved_tick"] = 9
assert_null(OpportunityStateRecord.from_dictionary(invalid_resolved))
invalid_resolved["resolved_tick"] = 10
invalid_resolved["resolution_event_id"] = valid.get_trigger_event_id()
assert_null(OpportunityStateRecord.from_dictionary(invalid_resolved))
func _open_fixture() -> Dictionary:
var system := FoodShortageOpportunitySystemScript.new()
var pantry := _empty_pantry()
var npc := _npc(0, 90.0)
var npcs: Array[SimNPC] = [npc]
var knowledge := EventKnowledgeSystem.new()
var trigger := _withdrawal_event(1, 10, npc.id)
_remember(knowledge, npc.id, 1, 10)
assert_not_null(system.consider_event(trigger, 10, pantry, npcs, knowledge, {}))
return {"system": system, "pantry": pantry, "npcs": npcs, "knowledge": knowledge}
func _empty_pantry() -> StorageStateRecord:
return StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_FOOD): 0.0}
)
func _npc(npc_id: int, hunger: float) -> SimNPC:
var npc := SimNPC.new(npc_id, "NPC %d" % npc_id, SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
npc.hunger = hunger
npc.is_dead = false
return npc
func _withdrawal_event(event_id: int, tick: int, actor_id: int) -> EconomicEventRecord:
return EconomicEventRecord.create(
event_id,
SimulationIds.EVENT_STORAGE_WITHDRAWN,
tick,
actor_id,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.npc_inventory_id(actor_id),
SimulationIds.RESOURCE_FOOD,
1.0
)
func _supply_event(
event_id: int, tick: int, event_type: StringName, actor_id: int
) -> EconomicEventRecord:
var source_id := &"player_resource"
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED and actor_id >= 0:
source_id = SimulationIds.npc_inventory_id(actor_id)
return EconomicEventRecord.create(
event_id,
event_type,
tick,
actor_id,
source_id,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
func _player_resource_states() -> Dictionary:
var resource_node := ResourceNode.new()
resource_node.node_id = &"player_resource"
resource_node.action_id = SimulationIds.ACTION_GATHER_FOOD
resource_node.resource_id = SimulationIds.RESOURCE_FOOD
resource_node.can_player_use = true
var resource_state := ResourceStateRecord.create_from_node(resource_node)
var result := {resource_node.node_id: resource_state}
resource_node.free()
return result
func _remember(
knowledge: EventKnowledgeSystem, knower_id: int, event_id: int, acquired_tick: int
) -> void:
var records := knowledge.get_all_sorted()
records.append(
KnownEventStateRecord.create(
knower_id,
event_id,
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
KnownEventStateRecord.NO_SOURCE_NPC_ID,
acquired_tick
)
)
knowledge.restore(records)
func _opportunity_id(opportunity: OpportunityStateRecord) -> int:
return opportunity.get_opportunity_id()
@@ -0,0 +1 @@
uid://cnodtf7lh6ona