feat: add knowledge-gated pantry opportunities
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user