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