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
+415 -11
View File
@@ -28,6 +28,13 @@ signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord)
signal opportunity_opened(opportunity: OpportunityStateRecord)
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
signal opportunity_invalidated(opportunity: OpportunityStateRecord)
signal situation_opened(situation: SituationStateRecord)
signal situation_closed(situation: SituationStateRecord)
signal quest_journal_changed(entry: QuestJournalEntryStateRecord)
signal commitment_changed(commitment: CommitmentStateRecord)
signal conversation_started(conversation_id: StringName, turn: ConversationTurn)
signal conversation_turn_changed(conversation_id: StringName, turn: ConversationTurn)
signal conversation_ended(conversation_id: StringName)
signal combatant_spawned(combatant_id: StringName)
signal combatant_died(combatant_id: StringName)
signal raid_started(raider_ids: Array[StringName])
@@ -57,6 +64,12 @@ var animal_care := AnimalCareSystemScript.new()
var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new()
var opportunity_system := VillageOpportunitySystem.new()
var situation_system := SituationSystem.create_default()
var quest_journal_system := QuestJournalSystem.new()
var commitment_system := SocialCommitmentSystem.new()
var commitment_lifecycle := CommitmentLifecycleService.new()
var conversation_service := ConversationService.new()
var conversation_history: Array[ConversationActStateRecord] = []
var conflict_system := ConflictSystemScript.new()
var storage_states: Dictionary:
get:
@@ -76,8 +89,14 @@ var target_resolver := ActionTargetResolver.new()
var last_player_resource_query_stats: Dictionary = {}
var _population_view := SimulationPopulationView.new()
var speed_index := 2
var _dialogue_speed_index := -1
var _active_player_conversation: StringName
var _conversation_context_by_id: Dictionary = {}
var _next_conversation_act_id := 0
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
const KNOWLEDGE_COMMUNICATION_RADIUS := 2.5
const MAX_CONVERSATION_ACTS_PER_PAIR := 8
const MAX_CONVERSATION_HISTORY := 1024
const SEASON_DAYS := 4
const COLD_DAYS := 3
const SEASON_COLD := &"cold"
@@ -214,6 +233,7 @@ func simulate_tick() -> void:
)
if invalidated != null:
opportunity_invalidated.emit(invalidated)
_maintain_emergent_world()
if tick_count % get_knowledge_review_interval() == 0:
_maintain_event_knowledge(true)
if debug_logs:
@@ -754,6 +774,7 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
for learned_record in learned_records:
_apply_new_event_knowledge(learned_record, event)
_update_opportunity_from_event(event)
_update_situations_from_event(event)
economic_event_recorded.emit(event)
@@ -844,6 +865,53 @@ func _update_opportunity_from_event(event: EconomicEventRecord) -> void:
_maintain_event_knowledge(false)
func _update_situations_from_event(event: EconomicEventRecord) -> void:
var world_event := event_log.get_world_event(int(event.data["event_id"]))
if world_event == null:
return
var interested_npc_id := int(event.data["actor_id"])
if interested_npc_id < 0 or _find_npc_by_id(interested_npc_id) == null:
_maintain_emergent_world()
return
var facts := _get_situation_facts(interested_npc_id)
for evaluation in situation_system.consider_event(world_event, tick_count, facts):
if evaluation.opened_situation_id < 0:
continue
var opened := situation_system.get_by_id(evaluation.opened_situation_id)
if opened != null:
situation_opened.emit(opened)
_maintain_emergent_world()
func _maintain_emergent_world() -> void:
var facts := _get_situation_facts()
var closed := situation_system.maintain(tick_count, event_log.world_events, facts)
var changed_commitments := commitment_lifecycle.maintain_and_record(
tick_count, commitment_system, situation_system, event_log, facts, relationship_system
)
for situation in closed:
situation_closed.emit(situation)
var journal_entry := quest_journal_system.synchronize(situation, tick_count)
if journal_entry != null:
quest_journal_changed.emit(journal_entry)
for commitment in changed_commitments:
var outcome_fact := commitment_lifecycle.record_outcome(
commitment, event_log, relationship_system
)
_share_commitment_fact_with_creditor(commitment, outcome_fact)
commitment_changed.emit(commitment)
func _get_situation_facts(interested_npc_id: int = -1) -> Dictionary:
var facts := {
"pantry.food": get_pantry().get_amount(SimulationIds.RESOURCE_FOOD),
"target_id": String(SimulationIds.STORAGE_VILLAGE_PANTRY),
}
if interested_npc_id >= 0:
facts["interested_npc_id"] = interested_npc_id
return facts
func _maintain_event_knowledge(expire_by_age: bool) -> void:
var forgotten := event_knowledge_system.maintain_retention(
tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age
@@ -858,17 +926,9 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]:
var lasting: Array[KnownEventStateRecord] = []
var seen := {}
for relationship in relationship_system.get_all_sorted():
var event_id := relationship.get_last_trust_cause_event_id()
if event_id == RelationshipStateRecord.NO_CAUSE_EVENT:
continue
var record := event_knowledge_system.get_record(relationship.get_observer_id(), event_id)
if record == null:
continue
var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()]
if seen.has(key):
continue
seen[key] = true
lasting.append(record)
for dimension in RelationshipStateRecord.DIMENSIONS:
var event_id := relationship.get_last_cause_event_id(dimension)
_append_lasting_knowledge(lasting, seen, relationship.get_observer_id(), event_id)
var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity()
if open_opportunity != null:
var opportunity_record := event_knowledge_system.get_record(
@@ -881,9 +941,35 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]:
if not seen.has(opportunity_key):
seen[opportunity_key] = true
lasting.append(opportunity_record)
for situation in situation_system.get_all_sorted():
var context := situation.get_context()
var interested_text := String(context.get("interested_npc_id", ""))
if interested_text.is_valid_int():
_append_lasting_knowledge(
lasting, seen, interested_text.to_int(), situation.get_trigger_event_id()
)
if quest_journal_system.get_for_situation(situation.get_situation_id()) != null:
_append_lasting_knowledge(
lasting, seen, SimulationIds.PLAYER_ACTOR_ID, situation.get_trigger_event_id()
)
return lasting
func _append_lasting_knowledge(
lasting: Array[KnownEventStateRecord], seen: Dictionary, knower_id: int, event_id: int
) -> void:
if event_id < 0:
return
var record := event_knowledge_system.get_record(knower_id, event_id)
if record == null:
return
var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()]
if seen.has(key):
return
seen[key] = true
lasting.append(record)
func _get_lasting_event_ids(npc_id: int) -> Array[int]:
var event_ids: Array[int] = []
for record in _get_lasting_knowledge_records():
@@ -960,6 +1046,285 @@ func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord:
return opportunity_system.get_latest_for_npc(npc_id)
func get_active_situations() -> Array[SituationStateRecord]:
return situation_system.get_active_sorted()
func get_quest_journal_entries() -> Array[QuestJournalEntryStateRecord]:
return quest_journal_system.get_all_sorted()
func get_active_commitments() -> Array[CommitmentStateRecord]:
return commitment_system.get_active_sorted()
func begin_player_conversation(npc_id: int) -> StringName:
if not _active_player_conversation.is_empty():
return &""
var npc := _find_npc_by_id(npc_id)
if npc == null or npc.is_dead:
return &""
var situation := _get_known_situation_for_npc(npc_id)
if situation != null:
_discover_situation_from_speaker(situation, npc_id)
var npc_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, StringName(str(npc_id)))
var player_ref := WorldEntityRef.create(
WorldEventRecord.ENTITY_TYPE_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
)
var context := _build_conversation_context(npc, situation)
var conversation_id := conversation_service.begin(npc_ref, player_ref, context)
if conversation_id.is_empty():
return &""
_active_player_conversation = conversation_id
_conversation_context_by_id[conversation_id] = {
"npc_id": npc_id,
"situation_id": situation.get_situation_id() if situation != null else -1,
}
_dialogue_speed_index = speed_index
speed_index = SPEED_LEVELS.find(1.0)
speed_changed.emit(SPEED_LEVELS[speed_index])
var turn := conversation_service.get_turn(conversation_id)
_record_conversation_act(turn)
conversation_started.emit(conversation_id, turn)
return conversation_id
func select_player_conversation_option(
conversation_id: StringName, option_id: StringName, expected_revision: int
) -> ConversationSelectionResult:
if conversation_id != _active_player_conversation:
return ConversationSelectionResult.new()
var current_turn := conversation_service.get_turn(conversation_id)
var selected_option := current_turn.get_option(option_id) if current_turn != null else null
var result := conversation_service.select_option(conversation_id, option_id, expected_revision)
if not result.was_accepted():
return result
if selected_option != null:
_apply_conversation_intent(conversation_id, selected_option.get_intent_id())
var turn := result.get_turn()
_record_conversation_act(turn)
conversation_turn_changed.emit(conversation_id, turn)
if turn != null and turn.is_terminal():
end_player_conversation(conversation_id, turn.get_revision())
return result
func end_player_conversation(conversation_id: StringName, expected_revision: int = -1) -> bool:
if conversation_id != _active_player_conversation:
return false
if not conversation_service.end(conversation_id, expected_revision):
return false
_active_player_conversation = &""
_conversation_context_by_id.erase(conversation_id)
if _dialogue_speed_index >= 0:
speed_index = clampi(_dialogue_speed_index, 0, SPEED_LEVELS.size() - 1)
_dialogue_speed_index = -1
speed_changed.emit(SPEED_LEVELS[speed_index])
conversation_ended.emit(conversation_id)
return true
func is_dialogue_active() -> bool:
return not _active_player_conversation.is_empty()
func _get_known_situation_for_npc(npc_id: int) -> SituationStateRecord:
var known: Array[SituationStateRecord] = []
for situation in situation_system.get_all_sorted():
var context := situation.get_context()
if String(context.get("interested_npc_id", "")) != str(npc_id):
continue
if event_knowledge_system.knows_event(npc_id, situation.get_trigger_event_id()):
known.append(situation)
if known.is_empty():
return null
known.sort_custom(
func(first: SituationStateRecord, second: SituationStateRecord) -> bool:
if first.get_created_tick() != second.get_created_tick():
return first.get_created_tick() > second.get_created_tick()
return first.get_situation_id() > second.get_situation_id()
)
return known[0]
func _discover_situation_from_speaker(situation: SituationStateRecord, npc_id: int) -> void:
var event := event_log.get_by_id(situation.get_trigger_event_id())
if event == null:
return
if not event_knowledge_system.knows_event(
SimulationIds.PLAYER_ACTOR_ID, event.data["event_id"]
):
var learned := event_knowledge_system.communicate_event_to_player(event, npc_id, tick_count)
if learned != null:
_apply_new_event_knowledge(learned, event)
event_knowledge_transferred.emit(npc_id, SimulationIds.PLAYER_ACTOR_ID, event)
event_knowledge_system.pin_event(SimulationIds.PLAYER_ACTOR_ID, int(event.data["event_id"]))
var existing := quest_journal_system.get_for_situation(situation.get_situation_id())
if existing == null and situation.is_active():
var definition := situation_system.get_definition(situation.get_definition_id())
var entry := quest_journal_system.add_situation(situation, definition, &"", tick_count)
if entry != null:
quest_journal_changed.emit(entry)
func _build_conversation_context(npc: SimNPC, situation: SituationStateRecord) -> Dictionary:
var topics: Array[StringName] = []
var trace: Array[StringName] = [&"speaker_available"]
var options: Dictionary = {}
var initial_intent := ConversationIntentIds.GREET
if situation != null:
var definition := situation_system.get_definition(situation.get_definition_id())
topics = definition.dialogue_topic_ids.duplicate()
trace.append(&"speaker_knows_trigger_fact")
trace.append(&"listener_learned_trigger_fact")
trace.append(StringName("evidence_event_%d" % situation.get_trigger_event_id()))
if npc.hunger >= 60.0:
trace.append(&"speaker_hungry")
var relationship := relationship_system.get_relationship(
npc.id, SimulationIds.PLAYER_ACTOR_ID
)
if relationship == null:
trace.append(&"relationship_unfamiliar")
elif relationship.get_familiarity() >= 0.5:
trace.append(&"relationship_familiar")
else:
trace.append(&"relationship_known")
var outcome := _get_latest_commitment_for_npc(npc.id, situation.get_situation_id())
if outcome != null and not outcome.is_active():
trace.append(StringName("commitment_%s" % outcome.get_status()))
if outcome.get_cause_event_id() >= 0:
trace.append(StringName("outcome_event_%d" % outcome.get_cause_event_id()))
match outcome.get_status():
CommitmentStateRecord.STATUS_FULFILLED:
initial_intent = ConversationIntentIds.THANK
CommitmentStateRecord.STATUS_BROKEN:
initial_intent = ConversationIntentIds.REPROACH
CommitmentStateRecord.STATUS_SUPERSEDED:
initial_intent = ConversationIntentIds.ACKNOWLEDGE_SUPERSESSION
options[ConversationIntentIds.GREET] = [
ConversationIntentIds.ASK_WELLBEING,
ConversationIntentIds.ASK_WHAT_HAPPENED,
ConversationIntentIds.OFFER_HELP,
ConversationIntentIds.DECLINE,
ConversationIntentIds.ASK_WHO_ELSE,
ConversationIntentIds.GOODBYE,
]
return {
"conversation_key": &"jajce_npc_%d" % npc.id,
"initial_intent_id": initial_intent,
"topic_ids": topics,
"option_intent_ids": options,
"reason_trace": trace,
}
func _get_latest_commitment_for_npc(npc_id: int, situation_id: int = -1) -> CommitmentStateRecord:
var latest: CommitmentStateRecord
for commitment in commitment_system.get_all_sorted():
var creditor := commitment.get_creditor()
if (
creditor.get_entity_type() != WorldEventRecord.ENTITY_TYPE_NPC
or String(creditor.get_entity_id()) != str(npc_id)
or (situation_id >= 0 and commitment.get_situation_id() != situation_id)
):
continue
if latest == null or commitment.get_commitment_id() > latest.get_commitment_id():
latest = commitment
return latest
func _apply_conversation_intent(conversation_id: StringName, intent_id: StringName) -> void:
if intent_id not in [ConversationIntentIds.OFFER_HELP, ConversationIntentIds.ACCEPT]:
return
var context: Dictionary = _conversation_context_by_id.get(conversation_id, {})
var situation := situation_system.get_by_id(int(context.get("situation_id", -1)))
var npc_id := int(context.get("npc_id", -1))
if situation == null or npc_id < 0:
return
var definition := situation_system.get_definition(situation.get_definition_id())
var alternative_id := SituationSystem.ALTERNATIVE_RESTOCK
var entry := quest_journal_system.get_for_situation(situation.get_situation_id())
if entry != null and entry.get_selected_alternative_id().is_empty():
quest_journal_system.select_alternative(situation, definition, alternative_id)
quest_journal_changed.emit(entry)
var player_ref := WorldEntityRef.create(
WorldEventRecord.ENTITY_TYPE_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
)
var npc_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, StringName(str(npc_id)))
var commitment := commitment_system.create_for_alternative(
situation, definition, alternative_id, player_ref, npc_ref, tick_count, tick_count + 20
)
if commitment != null:
var acceptance_fact := commitment_lifecycle.record_acceptance(
commitment, event_log, relationship_system
)
_share_commitment_fact_with_creditor(commitment, acceptance_fact)
commitment_changed.emit(commitment)
func _share_commitment_fact_with_creditor(
commitment: CommitmentStateRecord, fact: EconomicEventRecord
) -> void:
if commitment == null or fact == null:
return
var creditor := commitment.get_creditor()
if (
creditor.get_entity_type()
not in [WorldEventRecord.ENTITY_TYPE_NPC, WorldEventRecord.ENTITY_TYPE_PLAYER]
):
return
var creditor_text := String(creditor.get_entity_id())
if not creditor_text.is_valid_int():
return
var debtor := commitment.get_debtor()
var debtor_text := String(debtor.get_entity_id())
if not debtor_text.is_valid_int():
return
var shared := event_knowledge_system.communicate_event(
fact, debtor_text.to_int(), creditor_text.to_int(), tick_count
)
if shared != null:
_apply_new_event_knowledge(shared, fact)
func _record_conversation_act(turn: ConversationTurn) -> void:
if turn == null or turn.get_act() == null:
return
var act := turn.get_act()
var record := ConversationActStateRecord.create(
_next_conversation_act_id,
tick_count,
act.get_speaker(),
act.get_listener(),
act.get_intent_id(),
act.get_causal_topic_ids()
)
if record == null:
return
conversation_history.append(record)
_next_conversation_act_id += 1
_prune_conversation_history(record)
func _prune_conversation_history(latest: ConversationActStateRecord) -> void:
var latest_pair := _conversation_pair_key(latest)
var pair_count := 0
for index in range(conversation_history.size() - 1, -1, -1):
if _conversation_pair_key(conversation_history[index]) != latest_pair:
continue
pair_count += 1
if pair_count > MAX_CONVERSATION_ACTS_PER_PAIR:
conversation_history.remove_at(index)
while conversation_history.size() > MAX_CONVERSATION_HISTORY:
conversation_history.pop_front()
func _conversation_pair_key(act: ConversationActStateRecord) -> String:
var keys := [act.get_speaker().index_key(), act.get_listener().index_key()]
keys.sort()
return "%s|%s" % keys
func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
return event_log.get_by_id(opportunity.get_trigger_event_id() if opportunity != null else -1)
@@ -1305,6 +1670,10 @@ func create_state_record() -> SimulationStateRecord:
"wander_random_streams": wander_streams,
"next_event_id": next_event_id,
"next_opportunity_id": opportunity_system.next_opportunity_id,
"next_situation_id": situation_system.get_next_situation_id(),
"next_journal_entry_id": quest_journal_system.get_next_entry_id(),
"next_commitment_id": commitment_system.get_next_commitment_id(),
"next_conversation_act_id": _next_conversation_act_id,
"cycle_duration_seconds": clock.cycle_duration_seconds
}
record.village = VillageStateRecord.capture(village)
@@ -1329,6 +1698,14 @@ func create_state_record() -> SimulationStateRecord:
record.event_knowledge.append(known_event)
for opportunity in opportunity_system.get_all_sorted():
record.opportunities.append(opportunity)
for situation in situation_system.get_all_sorted():
record.situations.append(situation)
for journal_entry in quest_journal_system.get_all_sorted():
record.quest_journal.append(journal_entry)
for commitment in commitment_system.get_all_sorted():
record.commitments.append(commitment)
for conversation_act in conversation_history:
record.conversation_history.append(conversation_act)
record.player = player_system.player_state
conflict_system.append_state_records(record)
return record
@@ -1372,6 +1749,33 @@ func restore_state(record: SimulationStateRecord) -> bool:
relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge)
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
situation_system = SituationSystem.create_default()
if not situation_system.restore(record.situations, int(record.simulation["next_situation_id"])):
return false
if record.situations.is_empty():
var migratable_opportunities: Array[OpportunityStateRecord] = []
for opportunity in record.opportunities:
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
migratable_opportunities.append(opportunity)
if not situation_system.import_opportunities(migratable_opportunities).is_empty():
return false
quest_journal_system = QuestJournalSystem.new()
if not quest_journal_system.restore(
record.quest_journal, int(record.simulation["next_journal_entry_id"])
):
return false
commitment_system = SocialCommitmentSystem.new()
if not commitment_system.restore(
record.commitments, int(record.simulation["next_commitment_id"])
):
return false
commitment_lifecycle = CommitmentLifecycleService.new()
conversation_service = ConversationService.new()
conversation_history.assign(record.conversation_history)
_next_conversation_act_id = int(record.simulation["next_conversation_act_id"])
_active_player_conversation = &""
_conversation_context_by_id.clear()
_dialogue_speed_index = -1
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
wander_random_sources.clear()
var wander_streams: Array = record.simulation["wander_random_streams"]