feat: generate world-backed situations and commitments
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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
|
||||
Reference in New Issue
Block a user