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
+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