Files
gamedev-the-steward/tests/unit/test_situation_system.gd
T

299 lines
12 KiB
GDScript

extends GutTest
func test_pantry_definition_exposes_typed_lifecycle_contract() -> void:
var definition := SituationSystem.create_pantry_shortage_definition()
var errors := (
definition
. validate(
[
SituationSystem.PREDICATE_EVENT_TYPE,
SituationSystem.PREDICATE_EVENT_PAYLOAD_EQUALS,
SituationSystem.PREDICATE_EVENT_PARTICIPANT_ID,
SituationSystem.PREDICATE_EVENT_PANTRY_SUPPLIED,
],
[
SituationSystem.PREDICATE_STATE_NUMBER_LTE,
SituationSystem.PREDICATE_STATE_NUMBER_GTE,
],
[SituationSystem.EXPIRY_AGE_REACHED]
)
)
assert_true(errors.is_empty(), str(errors))
assert_eq(definition.situation_definition_id, SituationSystem.DEFINITION_PANTRY_SHORTAGE)
assert_eq(definition.dedupe_key_fields, [&"world_id", &"source_id"])
assert_eq(definition.severity, 70)
assert_eq(definition.priority, 80)
assert_eq(definition.cooldown_ticks, 50)
assert_eq(definition.expiry_ticks, 200)
assert_eq(definition.dialogue_topic_ids, [SituationSystem.DIALOGUE_TOPIC_PANTRY_SHORTAGE])
var alternative := definition.get_alternative(SituationSystem.ALTERNATIVE_RESTOCK)
assert_not_null(alternative)
assert_eq(alternative.commitment_terms["target_id"], "village_pantry")
assert_eq(alternative.commitment_terms["amount"], 1.0)
func test_generation_exposes_reasons_dedupes_and_enforces_cooldown() -> void:
var system := SituationSystem.create_default()
var event := _pantry_withdrawal(4, 10)
var opened_results := system.consider_event(event, 10, _pantry_facts(0.0))
assert_eq(opened_results.size(), 1)
assert_true(opened_results[0].matched)
assert_eq(opened_results[0].opened_situation_id, 0)
assert_eq(opened_results[0].reason_traces.size(), 5)
assert_string_contains(opened_results[0].reason_traces[-1], "opened situation")
var situation := system.get_by_id(0)
assert_not_null(situation)
assert_eq(situation.get_context()["source_id"], "village_pantry")
assert_eq(situation.get_context()["actor_id"], "2")
assert_eq(situation.get_context()["interested_npc_id"], "2")
var duplicate := system.consider_event(_pantry_withdrawal(5, 11), 11, _pantry_facts(0.0))[0]
assert_true(duplicate.matched)
assert_eq(duplicate.opened_situation_id, -1)
assert_string_contains(duplicate.reason_traces[-1], "dedupe blocked")
var world_events := WorldEventStore.new()
assert_true(world_events.append(_pantry_supply(8, 12)))
var closed := system.maintain(12, world_events, _pantry_facts(1.0))
assert_eq(closed, [situation])
assert_eq(situation.get_status(), SituationStateRecord.STATUS_RESOLVED)
assert_eq(situation.get_resolution_event_id(), 8)
var cooldown := system.consider_event(_pantry_withdrawal(6, 20), 20, _pantry_facts(0.0))[0]
assert_string_contains(cooldown.reason_traces[-1], "cooldown active until tick 62")
var reopened := system.consider_event(_pantry_withdrawal(7, 62), 62, _pantry_facts(0.0))[0]
assert_eq(reopened.opened_situation_id, 1)
func test_generator_caps_at_three_and_orders_by_priority_then_severity() -> void:
var definitions: Array[SituationDefinition] = [
_definition(&"lowest", 10, 10),
_definition(&"highest", 40, 10),
_definition(&"middle_high", 30, 40),
_definition(&"middle_low", 30, 20),
]
var system := SituationSystem.new()
assert_true(system.configure(definitions).is_empty())
var results := system.consider_event(_pantry_withdrawal(10, 5), 5, _pantry_facts(0.0))
assert_eq(results.size(), 4)
assert_eq(
results.map(func(result: SituationEvaluationResult) -> String: return result.definition_id),
["highest", "middle_high", "middle_low", "lowest"]
)
assert_eq(system.get_active_sorted().size(), SituationSystem.MAX_CONCURRENT_SITUATIONS)
assert_eq(
_definition_ids(system.get_active_sorted()), [&"highest", &"middle_high", &"middle_low"]
)
assert_string_contains(results[-1].reason_traces[-1], "concurrency cap 3 reached")
func test_nonmatching_events_trace_each_failed_fact_without_opening() -> void:
var system := SituationSystem.create_default()
var wrong_item := EconomicEventRecord.create(
1, &"storage_withdrawn", 3, 2, &"village_woodpile", &"npc_inventory_2", &"wood", 1.0
)
var result := (
system
. consider_event(
WorldEventRecord.from_economic_event(wrong_item, &"jajce"), 3, _pantry_facts(2.0)
)[0]
)
assert_false(result.matched)
assert_eq(result.opened_situation_id, -1)
assert_true(system.get_active_sorted().is_empty())
assert_true(
result.reason_traces.any(func(reason: String) -> bool: return "did not match" in reason)
)
func test_progress_and_journal_are_derived_from_world_facts() -> void:
var situation_system := SituationSystem.create_default()
situation_system.consider_event(_pantry_withdrawal(1, 10), 10, _pantry_facts(0.0))
var situation := situation_system.get_by_id(0)
var journal := QuestJournalSystem.new()
var entry := journal.add_situation(
situation,
situation_system.get_definition(situation.get_definition_id()),
SituationSystem.ALTERNATIVE_RESTOCK,
10
)
var entry_before := entry.to_dictionary()
var empty_progress := journal.derive_progress(
entry, situation_system, WorldEventStore.new(), _pantry_facts(0.0)
)
var partial_progress := journal.derive_progress(
entry, situation_system, WorldEventStore.new(), _pantry_facts(0.5)
)
var supplied_events := WorldEventStore.new()
assert_true(supplied_events.append(_pantry_supply(4, 12)))
var complete_progress := journal.derive_progress(
entry, situation_system, supplied_events, _pantry_facts(1.0)
)
assert_false(empty_progress.resolved)
assert_eq(empty_progress.ratio(), 0.0)
assert_false(partial_progress.resolved)
assert_eq(partial_progress.ratio(), 0.0)
assert_true(complete_progress.resolved)
assert_eq(complete_progress.ratio(), 1.0)
assert_eq(complete_progress.matched_event_id, 4)
assert_eq(entry.to_dictionary(), entry_before)
assert_false(entry.to_dictionary().has("progress"))
assert_eq(entry.get_discovered_tick(), 10)
assert_eq(entry.get_tracking_preference(), QuestJournalEntryStateRecord.TRACKING_AUTO)
assert_true(entry.set_tracking_preference(QuestJournalEntryStateRecord.TRACKING_TRACKED))
assert_eq(journal.get_for_situation(0), entry)
situation_system.maintain(12, supplied_events, _pantry_facts(1.0))
assert_eq(journal.synchronize(situation, 12), entry)
assert_eq(entry.get_status(), QuestJournalEntryStateRecord.STATUS_COMPLETED)
assert_eq(
QuestJournalEntryStateRecord.from_dictionary(entry.to_dictionary()).to_dictionary(),
entry.to_dictionary()
)
var archive_entry := QuestJournalEntryStateRecord.create(4, 9, &"archive_test", 10)
assert_true(archive_entry.archive(11))
assert_true(archive_entry.is_archived())
func test_commitments_fulfill_break_release_and_supersede() -> void:
var system := SituationSystem.create_default()
system.consider_event(_pantry_withdrawal(1, 10), 10, _pantry_facts(0.0))
var situation := system.get_by_id(0)
var definition := system.get_definition(situation.get_definition_id())
var debtor := WorldEntityRef.create(&"player", &"-1")
var creditor := WorldEntityRef.create(&"npc", &"4")
var commitments := SocialCommitmentSystem.new()
var fulfilled := commitments.create_for_alternative(
situation, definition, SituationSystem.ALTERNATIVE_RESTOCK, debtor, creditor, 10, 20
)
assert_not_null(fulfilled)
assert_eq(fulfilled.get_terms()["item_id"], "food")
assert_null(
commitments.create_for_alternative(
situation, definition, SituationSystem.ALTERNATIVE_RESTOCK, debtor, creditor, 10
)
)
var supplied_events := WorldEventStore.new()
assert_true(supplied_events.append(_pantry_supply(9, 12)))
assert_eq(commitments.maintain(12, system, supplied_events, _pantry_facts(1.0)), [fulfilled])
assert_eq(fulfilled.get_status(), CommitmentStateRecord.STATUS_FULFILLED)
assert_eq(fulfilled.get_cause_event_id(), 9)
var broken := CommitmentStateRecord.create(1, 2, debtor, creditor, {"amount": 1.0}, 10, 11)
var released := CommitmentStateRecord.create(2, 3, debtor, creditor, {"amount": 1.0}, 10)
var superseded := CommitmentStateRecord.create(3, 4, debtor, creditor, {"amount": 1.0}, 10)
assert_true(broken.close(CommitmentStateRecord.STATUS_BROKEN, 12, &"deadline_missed"))
assert_true(released.close(CommitmentStateRecord.STATUS_RELEASED, 12, &"mutual_release"))
assert_true(superseded.close(CommitmentStateRecord.STATUS_SUPERSEDED, 12, &"new_promise"))
assert_eq(broken.get_status(), CommitmentStateRecord.STATUS_BROKEN)
assert_eq(released.get_status(), CommitmentStateRecord.STATUS_RELEASED)
assert_eq(superseded.get_status(), CommitmentStateRecord.STATUS_SUPERSEDED)
for record in [fulfilled, broken, released, superseded]:
assert_eq(
CommitmentStateRecord.from_dictionary(record.to_dictionary()).to_dictionary(),
record.to_dictionary()
)
func test_opportunity_bridge_maps_pantry_identity_and_preserves_lifecycle() -> void:
var opportunity := OpportunityStateRecord.create(8, 10, 4, 2)
assert_true(opportunity.resolve(12, 14))
var system := SituationSystem.create_default()
assert_true(system.import_opportunities([opportunity]).is_empty())
var imported := system.get_by_id(8)
assert_not_null(imported)
assert_eq(imported.get_definition_id(), SituationSystem.DEFINITION_PANTRY_SHORTAGE)
assert_eq(imported.get_status(), SituationStateRecord.STATUS_RESOLVED)
assert_eq(imported.get_trigger_event_id(), 4)
assert_eq(imported.get_resolution_event_id(), 12)
assert_eq(imported.get_context()["interested_npc_id"], 2)
assert_eq(
SituationStateRecord.from_dictionary(imported.to_dictionary()).to_dictionary(),
imported.to_dictionary()
)
func test_record_parsers_reject_impossible_lifecycle_state() -> void:
var situation := SituationStateRecord.create(1, &"definition", "definition|world=jajce", 10)
var invalid_situation := situation.to_dictionary()
invalid_situation["status"] = String(SituationStateRecord.STATUS_RESOLVED)
invalid_situation["closed_tick"] = 9
invalid_situation["close_reason"] = "done"
assert_null(SituationStateRecord.from_dictionary(invalid_situation))
var entry := QuestJournalEntryStateRecord.create(1, 1, &"definition", 10)
var invalid_entry := entry.to_dictionary()
invalid_entry["status"] = String(QuestJournalEntryStateRecord.STATUS_COMPLETED)
assert_null(QuestJournalEntryStateRecord.from_dictionary(invalid_entry))
var entity := WorldEntityRef.create(&"npc", &"1")
assert_null(CommitmentStateRecord.create(1, 1, entity, entity, {"amount": 1.0}, 10))
var valid_commitment := CommitmentStateRecord.create(
1, 1, entity, WorldEntityRef.create(&"npc", &"2"), {"amount": 1.0}, 10
)
var invalid_commitment := valid_commitment.to_dictionary()
invalid_commitment["status"] = String(CommitmentStateRecord.STATUS_FULFILLED)
invalid_commitment["close_reason"] = "done"
assert_null(CommitmentStateRecord.from_dictionary(invalid_commitment))
func _pantry_withdrawal(event_id: int, tick: int) -> WorldEventRecord:
var economic := EconomicEventRecord.create(
event_id,
&"storage_withdrawn",
tick,
2,
&"village_pantry",
&"npc_inventory_2",
&"food",
1.0,
Vector3(2.0, 0.0, 3.0)
)
return WorldEventRecord.from_economic_event(economic, &"jajce", &"village_pantry")
func _pantry_facts(amount: float) -> Dictionary:
return {
"pantry.food": amount,
"target_id": "village_pantry",
}
func _pantry_supply(event_id: int, tick: int) -> WorldEventRecord:
var economic := EconomicEventRecord.create(
event_id,
&"storage_deposited",
tick,
3,
&"npc_inventory_3",
&"village_pantry",
&"food",
1.0,
Vector3(2.0, 0.0, 3.0)
)
return WorldEventRecord.from_economic_event(economic, &"jajce", &"village_pantry")
func _definition(definition_id: StringName, priority: int, severity: int) -> SituationDefinition:
var definition := SituationSystem.create_pantry_shortage_definition()
definition.situation_definition_id = definition_id
definition.priority = priority
definition.severity = severity
definition.cooldown_ticks = 0
return definition
func _definition_ids(situations: Array[SituationStateRecord]) -> Array[StringName]:
var result: Array[StringName] = []
for situation in situations:
result.append(situation.get_definition_id())
return result