feat: deliver emergent Jajce commitments

This commit is contained in:
Rijad Zuzo
2026-08-12 21:15:58 +02:00
parent dc0f59fcf0
commit 6b50580598
28 changed files with 2145 additions and 206 deletions
@@ -0,0 +1,252 @@
extends GutTest
const CommitmentLifecycleService := preload(
"res://simulation/situations/CommitmentLifecycleService.gd"
)
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
func test_debtor_fulfillment_records_exact_facts_and_idempotent_consequences() -> void:
var context := _open_commitment(SimulationIds.PLAYER_ACTOR_ID, 4, 20)
var lifecycle := CommitmentLifecycleService.new()
var acceptance := lifecycle.record_acceptance(
context.commitment, context.event_log, context.relationships
)
assert_not_null(acceptance)
assert_eq(StringName(acceptance.data["event_type"]), CommitmentLifecycleService.EVENT_ACCEPTED)
assert_eq(int(acceptance.data["commitment_id"]), context.commitment.get_commitment_id())
var relationship: RelationshipStateRecord = context.relationships.get_relationship(
4, SimulationIds.PLAYER_ACTOR_ID
)
assert_not_null(relationship)
assert_almost_eq(
relationship.get_obligation(), CommitmentLifecycleService.ACCEPTED_OBLIGATION, 0.0001
)
assert_eq(relationship.get_last_obligation_cause_event_id(), int(acceptance.data["event_id"]))
var resolution := _record_supply(context.event_log, 12, SimulationIds.PLAYER_ACTOR_ID)
var closed := lifecycle.maintain_and_record(
12,
context.commitments,
context.situations,
context.event_log,
_pantry_facts(1.0),
context.relationships
)
assert_eq(closed, [context.commitment])
assert_eq(context.commitment.get_status(), CommitmentStateRecord.STATUS_FULFILLED)
assert_eq(context.commitment.get_cause_event_id(), int(resolution.data["event_id"]))
var fulfilled := lifecycle.get_fact(
context.event_log,
CommitmentLifecycleService.EVENT_FULFILLED,
context.commitment.get_commitment_id()
)
assert_not_null(fulfilled)
assert_eq(int(fulfilled.data["cause_event_id"]), int(resolution.data["event_id"]))
assert_almost_eq(relationship.get_obligation(), 0.0, 0.0001)
assert_almost_eq(
relationship.get_trust(),
RelationshipStateRecord.NEUTRAL_TRUST + CommitmentLifecycleService.FULFILLED_TRUST,
0.0001
)
assert_eq(relationship.get_last_trust_cause_event_id(), int(fulfilled.data["event_id"]))
assert_eq(relationship.get_last_obligation_cause_event_id(), int(fulfilled.data["event_id"]))
var event_count: int = context.event_log.events.size()
var relationship_before := relationship.to_dictionary()
assert_same(
lifecycle.record_outcome(context.commitment, context.event_log, context.relationships),
fulfilled
)
assert_true(
(
lifecycle
. maintain_and_record(
13,
context.commitments,
context.situations,
context.event_log,
_pantry_facts(1.0),
context.relationships
)
. is_empty()
)
)
assert_eq(context.event_log.events.size(), event_count)
assert_eq(relationship.to_dictionary(), relationship_before)
_assert_fact_round_trip(context.event_log, fulfilled)
func test_deadline_breaks_at_due_tick_and_applies_one_exact_penalty() -> void:
var context := _open_commitment(2, 3, 12)
var lifecycle := CommitmentLifecycleService.new()
lifecycle.record_acceptance(context.commitment, context.event_log, context.relationships)
var relationship: RelationshipStateRecord = context.relationships.get_relationship(3, 2)
var closed := lifecycle.maintain_and_record(
12,
context.commitments,
context.situations,
context.event_log,
_pantry_facts(0.0),
context.relationships
)
assert_eq(closed, [context.commitment])
assert_eq(context.commitment.get_status(), CommitmentStateRecord.STATUS_BROKEN)
assert_eq(context.commitment.get_close_reason(), SocialCommitmentSystem.REASON_DEADLINE_MISSED)
var broken := lifecycle.get_fact(
context.event_log,
CommitmentLifecycleService.EVENT_BROKEN,
context.commitment.get_commitment_id()
)
assert_not_null(broken)
assert_almost_eq(relationship.get_obligation(), 0.0, 0.0001)
assert_almost_eq(
relationship.get_trust(),
RelationshipStateRecord.NEUTRAL_TRUST + CommitmentLifecycleService.BROKEN_TRUST,
0.0001
)
assert_almost_eq(
relationship.get_hostility(), CommitmentLifecycleService.BROKEN_HOSTILITY, 0.0001
)
for dimension in [
RelationshipStateRecord.DIMENSION_OBLIGATION,
RelationshipStateRecord.DIMENSION_TRUST,
RelationshipStateRecord.DIMENSION_HOSTILITY,
]:
assert_eq(relationship.get_last_cause_event_id(dimension), int(broken.data["event_id"]))
var before := relationship.to_dictionary()
assert_same(
lifecycle.record_outcome(context.commitment, context.event_log, context.relationships),
broken
)
assert_eq(relationship.to_dictionary(), before)
func test_other_npc_resolution_supersedes_without_trust_or_hostility_penalty() -> void:
var context := _open_commitment(2, 4, 20)
var lifecycle := CommitmentLifecycleService.new()
lifecycle.record_acceptance(context.commitment, context.event_log, context.relationships)
var relationship: RelationshipStateRecord = context.relationships.get_relationship(4, 2)
var resolution := _record_supply(context.event_log, 12, 3)
var closed := lifecycle.maintain_and_record(
12,
context.commitments,
context.situations,
context.event_log,
_pantry_facts(1.0),
context.relationships
)
assert_eq(closed, [context.commitment])
assert_eq(context.commitment.get_status(), CommitmentStateRecord.STATUS_SUPERSEDED)
assert_eq(
context.commitment.get_close_reason(), SocialCommitmentSystem.REASON_FACT_RESOLVED_BY_OTHER
)
assert_eq(context.commitment.get_cause_event_id(), int(resolution.data["event_id"]))
var superseded := lifecycle.get_fact(
context.event_log,
CommitmentLifecycleService.EVENT_SUPERSEDED,
context.commitment.get_commitment_id()
)
assert_not_null(superseded)
assert_almost_eq(relationship.get_obligation(), 0.0, 0.0001)
assert_almost_eq(relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST, 0.0001)
assert_almost_eq(relationship.get_hostility(), 0.0, 0.0001)
assert_eq(relationship.get_last_obligation_cause_event_id(), int(superseded.data["event_id"]))
assert_eq(relationship.get_last_trust_cause_event_id(), RelationshipStateRecord.NO_CAUSE_EVENT)
assert_eq(
relationship.get_last_hostility_cause_event_id(), RelationshipStateRecord.NO_CAUSE_EVENT
)
func test_released_commitment_records_fact_and_clears_obligation_without_penalty() -> void:
var context := _open_commitment(2, 4, 20)
var lifecycle := CommitmentLifecycleService.new()
var relationship: RelationshipStateRecord = context.relationships.get_or_create_relationship(
4, 2
)
assert_almost_eq(relationship.adjust_obligation(0.9, -1), 0.9, 0.0001)
var acceptance := lifecycle.record_acceptance(
context.commitment, context.event_log, context.relationships
)
assert_almost_eq(float(acceptance.data["obligation_delta"]), 0.1, 0.0001)
assert_almost_eq(relationship.get_obligation(), 1.0, 0.0001)
assert_true(context.commitments.release(0, 11, &"mutual_release"))
var released := lifecycle.record_outcome(
context.commitment, context.event_log, context.relationships
)
assert_not_null(released)
assert_eq(StringName(released.data["event_type"]), CommitmentLifecycleService.EVENT_RELEASED)
assert_almost_eq(relationship.get_obligation(), 0.9, 0.0001)
assert_almost_eq(relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST, 0.0001)
assert_almost_eq(relationship.get_hostility(), 0.0, 0.0001)
func _open_commitment(debtor_id: int, creditor_id: int, deadline_tick: int) -> Dictionary:
var event_log := SimulationEventLogScript.new()
var trigger := event_log.record_economic(
10,
&"storage_withdrawn",
creditor_id,
&"village_pantry",
StringName("npc_inventory_%d" % creditor_id),
&"food",
1.0
)
var situations := SituationSystem.create_default()
situations.consider_event(
event_log.get_world_event(int(trigger.data["event_id"])), 10, _pantry_facts(0.0)
)
var situation := situations.get_by_id(0)
var commitments := SocialCommitmentSystem.new()
var debtor_type := (
WorldEventRecord.ENTITY_TYPE_PLAYER
if debtor_id == SimulationIds.PLAYER_ACTOR_ID
else WorldEventRecord.ENTITY_TYPE_NPC
)
var commitment := commitments.create_for_alternative(
situation,
situations.get_definition(situation.get_definition_id()),
SituationSystem.ALTERNATIVE_RESTOCK,
WorldEntityRef.create(debtor_type, StringName(str(debtor_id))),
WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, StringName(str(creditor_id))),
10,
deadline_tick
)
return {
"commitment": commitment,
"commitments": commitments,
"event_log": event_log,
"relationships": RelationshipSystemScript.new(),
"situations": situations,
}
func _record_supply(event_log: RefCounted, tick: int, actor_id: int) -> EconomicEventRecord:
var source_id := (
&"player_inventory"
if actor_id == SimulationIds.PLAYER_ACTOR_ID
else StringName("npc_inventory_%d" % actor_id)
)
return event_log.record_economic(
tick, &"storage_deposited", actor_id, source_id, &"village_pantry", &"food", 1.0
)
func _pantry_facts(amount: float) -> Dictionary:
return {"pantry.food": amount, "target_id": "village_pantry"}
func _assert_fact_round_trip(event_log: RefCounted, expected: EconomicEventRecord) -> void:
var records: Array[EconomicEventRecord] = []
for event in event_log.events:
records.append(EconomicEventRecord.from_dictionary(event.to_dictionary()))
var restored := SimulationEventLogScript.new()
restored.restore(records, event_log.next_event_id)
var actual := restored.get_by_id(int(expected.data["event_id"]))
assert_not_null(actual)
assert_eq(actual.to_dictionary(), expected.to_dictionary())
assert_not_null(restored.get_world_event(int(expected.data["event_id"])))
@@ -0,0 +1 @@
uid://1walx8xflan0
@@ -0,0 +1,86 @@
extends GutTest
const MANAGER_SCRIPT := preload("res://simulation/SimulationManager.gd")
var manager: Node
func before_each() -> void:
manager = MANAGER_SCRIPT.new()
add_child_autofree(manager)
func test_world_v14_migrates_to_empty_emergent_collections() -> void:
var previous: Dictionary = manager.create_state_record().to_dictionary()
previous["schema_version"] = SimulationStateRecord.PRE_EMERGENT_SCHEMA_VERSION
for field in ["situations", "quest_journal", "commitments", "conversation_history"]:
previous.erase(field)
for field in [
"next_situation_id",
"next_journal_entry_id",
"next_commitment_id",
"next_conversation_act_id",
]:
previous["simulation"].erase(field)
var migrated: SimulationStateRecord = SimulationStateRecord.from_dictionary(previous)
assert_not_null(migrated)
assert_true(migrated.situations.is_empty())
assert_true(migrated.quest_journal.is_empty())
assert_true(migrated.commitments.is_empty())
assert_true(migrated.conversation_history.is_empty())
assert_eq(int(migrated.simulation["next_situation_id"]), 0)
assert_eq(int(migrated.simulation["next_conversation_act_id"]), 0)
func test_orphan_journal_and_situation_evidence_are_rejected() -> void:
var orphan_journal: Dictionary = manager.create_state_record().to_dictionary()
orphan_journal["quest_journal"] = [
(
QuestJournalEntryStateRecord
. create(0, 42, SituationSystem.DEFINITION_PANTRY_SHORTAGE, 0)
. to_dictionary()
)
]
orphan_journal["simulation"]["next_journal_entry_id"] = 1
assert_null(SimulationStateRecord.from_dictionary(orphan_journal))
var missing_evidence: Dictionary = manager.create_state_record().to_dictionary()
missing_evidence["situations"] = [
(
SituationStateRecord
. create(
0,
SituationSystem.DEFINITION_PANTRY_SHORTAGE,
"missing-evidence",
0,
999,
{"interested_npc_id": "0"}
)
. to_dictionary()
)
]
missing_evidence["simulation"]["next_situation_id"] = 1
assert_null(SimulationStateRecord.from_dictionary(missing_evidence))
func test_conversation_history_requires_valid_actors_and_monotonic_id() -> void:
var state: Dictionary = manager.create_state_record().to_dictionary()
var npc_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, &"0")
var player_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_PLAYER, &"-1")
state["conversation_history"] = [
(
ConversationActStateRecord
. create(
0, 0, npc_ref, player_ref, ConversationIntentIds.GREET, [&"topic_pantry_shortage"]
)
. to_dictionary()
)
]
assert_null(
SimulationStateRecord.from_dictionary(state),
"The next stable act ID cannot collide with retained history"
)
state["simulation"]["next_conversation_act_id"] = 1
assert_not_null(SimulationStateRecord.from_dictionary(state))
state["conversation_history"][0]["speaker"]["entity_id"] = "missing_npc"
assert_null(SimulationStateRecord.from_dictionary(state))
@@ -0,0 +1 @@
uid://hr1yu4alsrsx
+9 -4
View File
@@ -181,7 +181,7 @@ func test_commitments_fulfill_break_release_and_supersede() -> void:
)
)
var supplied_events := WorldEventStore.new()
assert_true(supplied_events.append(_pantry_supply(9, 12)))
assert_true(supplied_events.append(_pantry_supply(9, 12, SimulationIds.PLAYER_ACTOR_ID)))
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)
@@ -267,13 +267,18 @@ func _pantry_facts(amount: float) -> Dictionary:
}
func _pantry_supply(event_id: int, tick: int) -> WorldEventRecord:
func _pantry_supply(event_id: int, tick: int, actor_id: int = 3) -> WorldEventRecord:
var source_id := (
&"player_inventory"
if actor_id == SimulationIds.PLAYER_ACTOR_ID
else StringName("npc_inventory_%d" % actor_id)
)
var economic := EconomicEventRecord.create(
event_id,
&"storage_deposited",
tick,
3,
&"npc_inventory_3",
actor_id,
source_id,
&"village_pantry",
&"food",
1.0,