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