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"]
@@ -1,8 +1,8 @@
class_name SimulationScalingBenchmark
extends RefCounted
const SCHEMA_VERSION := 2
const WORKLOAD_ID := &"full_fidelity_combatant_headless_arrival_v2"
const SCHEMA_VERSION := 3
const WORKLOAD_ID := &"full_fidelity_emergent_headless_arrival_v3"
const RECENT_FACTS_PER_NPC := 3
const STARTING_CLOCK_TICK := 50
@@ -25,6 +25,14 @@ func prepare_manager(
var no_opportunities: Array[OpportunityStateRecord] = []
manager.relationship_system.restore(no_relationships)
manager.opportunity_system.restore(no_opportunities, 0)
var no_situations: Array[SituationStateRecord] = []
var no_journal_entries: Array[QuestJournalEntryStateRecord] = []
var no_commitments: Array[CommitmentStateRecord] = []
manager.situation_system.restore(no_situations, 0)
manager.quest_journal_system.restore(no_journal_entries, 0)
manager.commitment_system.restore(no_commitments, 0)
manager.conversation_history.clear()
manager._next_conversation_act_id = 0
for npc_id in population:
var npc_random := _create_random_source(seed_value, npc_id, 0)
@@ -83,6 +91,8 @@ func measure_manager(
var start_json: String = manager.serialize_state()
var start_event_count: int = manager.economic_events.size()
var start_known_count: int = manager.event_knowledge_system.get_all_sorted().size()
var start_situation_count: int = manager.situation_system.get_all_sorted().size()
var start_commitment_count: int = manager.commitment_system.get_all_sorted().size()
var start_tick: int = manager.tick_count
var measured_arrivals := 0
var simulation_usec := 0
@@ -134,6 +144,11 @@ func measure_manager(
"events_recorded": manager.economic_events.size() - start_event_count,
"start_known_reference_count": start_known_count,
"end_known_reference_count": manager.event_knowledge_system.get_all_sorted().size(),
"start_situation_count": start_situation_count,
"end_situation_count": manager.situation_system.get_all_sorted().size(),
"start_commitment_count": start_commitment_count,
"end_commitment_count": manager.commitment_system.get_all_sorted().size(),
"conversation_act_count": manager.conversation_history.size(),
"final_checksum": end_json.sha256_text(),
}
+5
View File
@@ -67,6 +67,11 @@ const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
const EVENT_ANIMAL_FED := &"animal_fed"
const EVENT_VILLAGER_WEAK := &"villager_weak"
const EVENT_HOME_DAMAGED := &"home_damaged"
const EVENT_COMMITMENT_ACCEPTED := &"commitment_accepted"
const EVENT_COMMITMENT_FULFILLED := &"commitment_fulfilled"
const EVENT_COMMITMENT_BROKEN := &"commitment_broken"
const EVENT_COMMITMENT_RELEASED := &"commitment_released"
const EVENT_COMMITMENT_SUPERSEDED := &"commitment_superseded"
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
+57
View File
@@ -4,6 +4,21 @@ signal event_recorded(event: EconomicEventRecord)
const DEFAULT_RATE_WINDOW_TICKS := 200
const DEFAULT_TICKS_PER_DAY := 200.0
const FACT_RESERVED_FIELDS := [
"schema_version",
"event_id",
"event_type",
"tick",
"actor_id",
"source_id",
"destination_id",
"item_id",
"amount",
"action_name",
"action_id",
"required_amount",
"world_position",
]
var events: Array[EconomicEventRecord] = []
var next_event_id := 0
@@ -69,6 +84,48 @@ func record_narrative(
return event
func record_fact(
tick: int,
event_type: StringName,
actor_id: int,
source_id: StringName = &"",
destination_id: StringName = &"",
fact_payload: Dictionary = {},
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord:
if tick < 0 or event_type.is_empty():
return null
var event := EconomicEventRecord.create_narrative(
next_event_id, event_type, tick, actor_id, source_id, "", world_position
)
event.data["destination_id"] = String(destination_id)
var payload_keys: Array = fact_payload.keys()
payload_keys.sort_custom(
func(first: Variant, second: Variant) -> bool: return str(first) < str(second)
)
var normalized_keys := {}
for raw_key in payload_keys:
if not raw_key is String and not raw_key is StringName:
return null
var key := String(raw_key)
if key.is_empty() or key in FACT_RESERVED_FIELDS or normalized_keys.has(key):
return null
normalized_keys[key] = true
event.data[key] = fact_payload[raw_key]
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
if normalized == null:
return null
if (
WorldEventRecord.from_economic_event(
normalized, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE
)
== null
):
return null
_append(normalized)
return normalized
func get_for_actor(actor_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
var results: Array[EconomicEventRecord] = []
var event_ids: Array = _event_ids_by_actor.get(actor_id, [])
@@ -317,6 +317,23 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool:
return false
var event_type := StringName(event.data["event_type"])
var item_id := StringName(event.data["item_id"])
if (
event_type
in [
SimulationIds.EVENT_COMMITMENT_ACCEPTED,
SimulationIds.EVENT_COMMITMENT_FULFILLED,
SimulationIds.EVENT_COMMITMENT_BROKEN,
SimulationIds.EVENT_COMMITMENT_RELEASED,
SimulationIds.EVENT_COMMITMENT_SUPERSEDED,
]
):
return (
KnownEventStateRecord.is_valid_actor_id(int(event.data["actor_id"]))
and int(event.data.get("commitment_id", -1)) >= 0
and event.data.get("debtor") is Dictionary
and event.data.get("creditor") is Dictionary
and event.data.get("terms") is Dictionary
)
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
return (
int(event.data["actor_id"]) >= 0
@@ -0,0 +1,250 @@
class_name CommitmentLifecycleService
extends RefCounted
const EVENT_ACCEPTED := SimulationIds.EVENT_COMMITMENT_ACCEPTED
const EVENT_FULFILLED := SimulationIds.EVENT_COMMITMENT_FULFILLED
const EVENT_BROKEN := SimulationIds.EVENT_COMMITMENT_BROKEN
const EVENT_RELEASED := SimulationIds.EVENT_COMMITMENT_RELEASED
const EVENT_SUPERSEDED := SimulationIds.EVENT_COMMITMENT_SUPERSEDED
const ACCEPTED_OBLIGATION := 0.25
const FULFILLED_TRUST := 0.10
const BROKEN_TRUST := -0.20
const BROKEN_HOSTILITY := 0.15
func record_acceptance(
commitment: CommitmentStateRecord, event_log: RefCounted, relationships: RefCounted
) -> EconomicEventRecord:
if commitment == null or not commitment.is_active() or event_log == null:
return null
var event := _find_fact(event_log, EVENT_ACCEPTED, commitment.get_commitment_id())
if event == null:
var relationship := _get_consequence_relationship(commitment, relationships)
var obligation_delta := (
minf(ACCEPTED_OBLIGATION, 1.0 - relationship.get_obligation())
if relationship != null
else 0.0
)
event = _record_fact(
commitment,
event_log,
EVENT_ACCEPTED,
commitment.get_created_tick(),
CommitmentStateRecord.STATUS_ACTIVE,
&"accepted",
CommitmentStateRecord.NO_EVENT_ID,
{"obligation_delta": obligation_delta}
)
if event != null:
_apply_acceptance(commitment, event, relationships)
return event
func maintain_and_record(
current_tick: int,
commitments: SocialCommitmentSystem,
situations: SituationSystem,
event_log: RefCounted,
state_facts: Dictionary,
relationships: RefCounted
) -> Array[CommitmentStateRecord]:
if commitments == null or situations == null or event_log == null:
return []
var closed := commitments.maintain(
current_tick, situations, event_log.world_events, state_facts
)
for commitment in closed:
record_outcome(commitment, event_log, relationships)
return closed
func record_outcome(
commitment: CommitmentStateRecord, event_log: RefCounted, relationships: RefCounted
) -> EconomicEventRecord:
if commitment == null or commitment.is_active() or event_log == null:
return null
var event_type := _event_type_for_status(commitment.get_status())
if event_type.is_empty():
return null
var event := _find_fact(event_log, event_type, commitment.get_commitment_id())
if event == null:
var acceptance := _find_fact(event_log, EVENT_ACCEPTED, commitment.get_commitment_id())
var obligation_delta := (
float(acceptance.data.get("obligation_delta", ACCEPTED_OBLIGATION))
if acceptance != null
else 0.0
)
event = _record_fact(
commitment,
event_log,
event_type,
commitment.get_closed_tick(),
commitment.get_status(),
commitment.get_close_reason(),
commitment.get_cause_event_id(),
{"obligation_delta": obligation_delta}
)
if event != null:
_apply_outcome(commitment, event, relationships)
return event
func synchronize_outcomes(
commitments: SocialCommitmentSystem, event_log: RefCounted, relationships: RefCounted
) -> Array[EconomicEventRecord]:
var events: Array[EconomicEventRecord] = []
if commitments == null:
return events
for commitment in commitments.get_all_sorted():
if commitment.is_active():
continue
var event := record_outcome(commitment, event_log, relationships)
if event != null:
events.append(event)
return events
func get_fact(
event_log: RefCounted, event_type: StringName, commitment_id: int
) -> EconomicEventRecord:
return _find_fact(event_log, event_type, commitment_id)
func _record_fact(
commitment: CommitmentStateRecord,
event_log: RefCounted,
event_type: StringName,
tick: int,
status: StringName,
reason: StringName,
cause_event_id: int,
additional_payload: Dictionary = {}
) -> EconomicEventRecord:
var debtor := commitment.get_debtor()
var creditor := commitment.get_creditor()
var debtor_actor_id := _relationship_actor_id(debtor)
if not RelationshipStateRecord.is_valid_actor_id(debtor_actor_id):
return null
var payload := {
"cause_event_id": cause_event_id,
"close_reason": String(reason),
"commitment_id": commitment.get_commitment_id(),
"creditor": creditor.to_dictionary(),
"debtor": debtor.to_dictionary(),
"situation_id": commitment.get_situation_id(),
"status": String(status),
"terms": commitment.get_terms(),
}
for key in additional_payload:
if payload.has(key):
return null
payload[key] = additional_payload[key]
return event_log.record_fact(
tick,
event_type,
debtor_actor_id,
StringName("commitment_%d" % commitment.get_commitment_id()),
StringName(creditor.index_key()),
payload
)
func _apply_acceptance(
commitment: CommitmentStateRecord, event: EconomicEventRecord, relationships: RefCounted
) -> void:
var relationship := _get_consequence_relationship(commitment, relationships)
if relationship == null:
return
_apply_dimension_once(
relationship,
RelationshipStateRecord.DIMENSION_OBLIGATION,
float(event.data.get("obligation_delta", ACCEPTED_OBLIGATION)),
int(event.data["event_id"])
)
func _apply_outcome(
commitment: CommitmentStateRecord, event: EconomicEventRecord, relationships: RefCounted
) -> void:
var relationship := _get_consequence_relationship(commitment, relationships)
if relationship == null:
return
var event_id := int(event.data["event_id"])
_apply_dimension_once(
relationship,
RelationshipStateRecord.DIMENSION_OBLIGATION,
-float(event.data.get("obligation_delta", ACCEPTED_OBLIGATION)),
event_id
)
match commitment.get_status():
CommitmentStateRecord.STATUS_FULFILLED:
_apply_dimension_once(
relationship, RelationshipStateRecord.DIMENSION_TRUST, FULFILLED_TRUST, event_id
)
CommitmentStateRecord.STATUS_BROKEN:
_apply_dimension_once(
relationship, RelationshipStateRecord.DIMENSION_TRUST, BROKEN_TRUST, event_id
)
_apply_dimension_once(
relationship,
RelationshipStateRecord.DIMENSION_HOSTILITY,
BROKEN_HOSTILITY,
event_id
)
static func _apply_dimension_once(
relationship: RelationshipStateRecord, dimension: StringName, amount: float, cause_event_id: int
) -> void:
if relationship.get_last_cause_event_id(dimension) >= cause_event_id:
return
relationship.adjust_dimension(dimension, amount, cause_event_id)
static func _get_consequence_relationship(
commitment: CommitmentStateRecord, relationships: RefCounted
) -> RelationshipStateRecord:
if relationships == null:
return null
var observer_id := _relationship_actor_id(commitment.get_creditor())
var subject_id := _relationship_actor_id(commitment.get_debtor())
return relationships.get_or_create_relationship(observer_id, subject_id)
static func _relationship_actor_id(entity: WorldEntityRef) -> int:
if entity == null:
return -2147483648
var entity_id := String(entity.get_entity_id())
if not entity_id.is_valid_int():
return -2147483648
var actor_id := entity_id.to_int()
if entity.get_entity_type() == WorldEventRecord.ENTITY_TYPE_PLAYER:
return actor_id if actor_id == SimulationIds.PLAYER_ACTOR_ID else -2147483648
if entity.get_entity_type() != WorldEventRecord.ENTITY_TYPE_NPC or actor_id < 0:
return -2147483648
return actor_id
static func _find_fact(
event_log: RefCounted, event_type: StringName, commitment_id: int
) -> EconomicEventRecord:
if event_log == null or commitment_id < 0:
return null
for event in event_log.get_for_type(event_type):
if int(event.data.get("commitment_id", -1)) == commitment_id:
return event
return null
static func _event_type_for_status(status: StringName) -> StringName:
match status:
CommitmentStateRecord.STATUS_FULFILLED:
return EVENT_FULFILLED
CommitmentStateRecord.STATUS_BROKEN:
return EVENT_BROKEN
CommitmentStateRecord.STATUS_RELEASED:
return EVENT_RELEASED
CommitmentStateRecord.STATUS_SUPERSEDED:
return EVENT_SUPERSEDED
return &""
@@ -0,0 +1 @@
uid://rhqx2yahw4rj
+24 -2
View File
@@ -17,12 +17,13 @@ func add_situation(
or definition == null
or not situation.is_active()
or situation.get_definition_id() != definition.situation_definition_id
or definition.get_alternative(alternative_id) == null
or (not alternative_id.is_empty() and definition.get_alternative(alternative_id) == null)
or current_tick < situation.get_created_tick()
or _entry_id_by_situation.has(situation.get_situation_id())
or not situation.select_alternative(alternative_id)
):
return null
if not alternative_id.is_empty() and not situation.select_alternative(alternative_id):
return null
var entry := QuestJournalEntryStateRecord.create(
_next_entry_id,
situation.get_situation_id(),
@@ -36,6 +37,23 @@ func add_situation(
return entry
func select_alternative(
situation: SituationStateRecord, definition: SituationDefinition, alternative_id: StringName
) -> bool:
if (
situation == null
or definition == null
or alternative_id.is_empty()
or situation.get_definition_id() != definition.situation_definition_id
or definition.get_alternative(alternative_id) == null
):
return false
var entry := get_for_situation(situation.get_situation_id())
if entry == null or not entry.is_active():
return false
return situation.select_alternative(alternative_id) and entry.select_alternative(alternative_id)
func synchronize(
situation: SituationStateRecord, current_tick: int
) -> QuestJournalEntryStateRecord:
@@ -115,3 +133,7 @@ func get_active_sorted() -> Array[QuestJournalEntryStateRecord]:
if record.is_active():
records.append(record)
return records
func get_next_entry_id() -> int:
return _next_entry_id
+4
View File
@@ -314,6 +314,10 @@ func get_active_sorted() -> Array[SituationStateRecord]:
return active
func get_next_situation_id() -> int:
return _next_situation_id
func _get_definitions_sorted() -> Array[SituationDefinition]:
var definitions: Array[SituationDefinition] = []
for definition in _definitions_by_id.values():
@@ -2,6 +2,7 @@ class_name SocialCommitmentSystem
extends RefCounted
const REASON_FACT_FULFILLED := &"world_fact_fulfilled"
const REASON_FACT_RESOLVED_BY_OTHER := &"world_fact_resolved_by_other"
const REASON_DEADLINE_MISSED := &"deadline_missed"
const REASON_SITUATION_EXPIRED := &"situation_expired"
const REASON_SITUATION_SUPERSEDED := &"situation_superseded"
@@ -70,11 +71,28 @@ func maintain(
situation, world_events, state_facts, situation.get_selected_alternative_id()
)
if progress.resolved:
var resolution_event := (
world_events.get_by_id(progress.matched_event_id) if world_events != null else null
)
var resolver := (
resolution_event.get_participant(WorldEventRecord.ROLE_ACTOR)
if resolution_event != null
else null
)
var debtor_resolved := resolver != null and resolver.equals(commitment.get_debtor())
var resolution_status := (
CommitmentStateRecord.STATUS_FULFILLED
if debtor_resolved
else CommitmentStateRecord.STATUS_SUPERSEDED
)
var resolution_reason := (
REASON_FACT_FULFILLED if debtor_resolved else REASON_FACT_RESOLVED_BY_OTHER
)
if _close(
commitment,
CommitmentStateRecord.STATUS_FULFILLED,
resolution_status,
current_tick,
REASON_FACT_FULFILLED,
resolution_reason,
progress.matched_event_id
):
closed.append(commitment)
@@ -98,7 +116,7 @@ func maintain(
closed.append(commitment)
continue
var deadline := commitment.get_deadline_tick()
if deadline >= 0 and current_tick > deadline:
if deadline >= 0 and current_tick >= deadline:
if _close(
commitment,
CommitmentStateRecord.STATUS_BROKEN,
@@ -164,6 +182,10 @@ func get_active_sorted() -> Array[CommitmentStateRecord]:
return records
func get_next_commitment_id() -> int:
return _next_commitment_id
func _close(
commitment: CommitmentStateRecord,
status: StringName,
@@ -0,0 +1,111 @@
class_name ConversationActStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
const MAX_TOPICS := 8
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create(
act_id: int,
tick: int,
speaker: WorldEntityRef,
listener: WorldEntityRef,
intent_id: StringName,
topic_ids: Array[StringName] = []
) -> ConversationActStateRecord:
if (
act_id < 0
or tick < 0
or speaker == null
or listener == null
or not speaker.is_valid()
or not listener.is_valid()
or speaker.equals(listener)
or not ConversationIntentIds.is_supported(intent_id)
or topic_ids.size() > MAX_TOPICS
):
return null
var normalized_topics: Array[String] = []
var seen: Dictionary = {}
for topic_id in topic_ids:
if topic_id.is_empty() or seen.has(topic_id):
return null
seen[topic_id] = true
normalized_topics.append(String(topic_id))
return (
ConversationActStateRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"act_id": act_id,
"tick": tick,
"speaker": speaker.to_dictionary(),
"listener": listener.to_dictionary(),
"intent_id": String(intent_id),
"topic_ids": normalized_topics,
}
)
)
static func from_dictionary(record_data: Dictionary) -> ConversationActStateRecord:
if (
int(record_data.get("schema_version", -1)) != SCHEMA_VERSION
or not record_data.has_all(
["act_id", "tick", "speaker", "listener", "intent_id", "topic_ids"]
)
or not record_data["speaker"] is Dictionary
or not record_data["listener"] is Dictionary
or not record_data["topic_ids"] is Array
):
return null
var topics: Array[StringName] = []
for topic in record_data["topic_ids"]:
if not topic is String and not topic is StringName:
return null
topics.append(StringName(topic))
return create(
int(record_data["act_id"]),
int(record_data["tick"]),
WorldEntityRef.from_dictionary(record_data["speaker"]),
WorldEntityRef.from_dictionary(record_data["listener"]),
StringName(record_data["intent_id"]),
topics
)
func get_act_id() -> int:
return int(data["act_id"])
func get_tick() -> int:
return int(data["tick"])
func get_speaker() -> WorldEntityRef:
return WorldEntityRef.from_dictionary(data["speaker"])
func get_listener() -> WorldEntityRef:
return WorldEntityRef.from_dictionary(data["listener"])
func get_intent_id() -> StringName:
return StringName(data["intent_id"])
func get_topic_ids() -> Array[StringName]:
var result: Array[StringName] = []
for topic in data["topic_ids"]:
result.append(StringName(topic))
return result
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://b4iruxxxc2g2j
@@ -154,6 +154,16 @@ func set_tracking_preference(preference: StringName) -> bool:
return true
func select_alternative(alternative_id: StringName) -> bool:
if not is_active() or alternative_id.is_empty():
return false
var selected := get_selected_alternative_id()
if not selected.is_empty() and selected != alternative_id:
return false
data["selected_alternative_id"] = String(alternative_id)
return true
func archive(closed_tick: int) -> bool:
return close(STATUS_ARCHIVED, closed_tick)
+121 -26
View File
@@ -2,7 +2,7 @@ class_name SimulationStateRecord
extends RefCounted
const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 14
const SCHEMA_VERSION := 15
const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
@@ -15,6 +15,7 @@ const ROUTINE_LEGACY_SCHEMA_VERSION := 10
const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11
const PLAYER_NEEDS_SCHEMA_VERSION := 12
const CONFLICT_SCHEMA_VERSION := 13
const PRE_EMERGENT_SCHEMA_VERSION := 14
const PREVIOUS_SCHEMA_VERSION := 7
var simulation: Dictionary
@@ -27,6 +28,10 @@ var economic_events: Array[EconomicEventRecord] = []
var relationships: Array[RelationshipStateRecord] = []
var event_knowledge: Array[KnownEventStateRecord] = []
var opportunities: Array[OpportunityStateRecord] = []
var situations: Array[SituationStateRecord] = []
var quest_journal: Array[QuestJournalEntryStateRecord] = []
var commitments: Array[CommitmentStateRecord] = []
var conversation_history: Array[ConversationActStateRecord] = []
var player: PlayerStateRecord
var combatants: Array[CombatantStateRecord] = []
var factions: Array[FactionStateRecord] = []
@@ -58,6 +63,18 @@ func to_dictionary() -> Dictionary:
var opportunity_data: Array[Dictionary] = []
for opportunity_record in opportunities:
opportunity_data.append(opportunity_record.to_dictionary())
var situation_data: Array[Dictionary] = []
for situation_record in situations:
situation_data.append(situation_record.to_dictionary())
var journal_data: Array[Dictionary] = []
for journal_record in quest_journal:
journal_data.append(journal_record.to_dictionary())
var commitment_data: Array[Dictionary] = []
for commitment_record in commitments:
commitment_data.append(commitment_record.to_dictionary())
var conversation_history_data: Array[Dictionary] = []
for conversation_act in conversation_history:
conversation_history_data.append(conversation_act.to_dictionary())
var combatant_data: Array[Dictionary] = []
for combatant_record in combatants:
combatant_data.append(combatant_record.to_dictionary())
@@ -78,6 +95,10 @@ func to_dictionary() -> Dictionary:
"relationships": relationship_data,
"event_knowledge": knowledge_data,
"opportunities": opportunity_data,
"situations": situation_data,
"quest_journal": journal_data,
"commitments": commitment_data,
"conversation_history": conversation_history_data,
"player": player.to_dictionary(),
"combatants": combatant_data,
"factions": faction_data
@@ -115,6 +136,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
PLAYER_NEEDS_SCHEMA_VERSION,
CONFLICT_SCHEMA_VERSION,
PRE_EMERGENT_SCHEMA_VERSION,
]
):
record_data = _migrate_legacy(record_data, version)
@@ -134,6 +156,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
"relationships",
"event_knowledge",
"opportunities",
"situations",
"quest_journal",
"commitments",
"conversation_history",
"player",
"combatants",
"factions",
@@ -157,6 +183,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
"wander_random_streams",
"next_event_id",
"next_opportunity_id",
"next_situation_id",
"next_journal_entry_id",
"next_commitment_id",
"next_conversation_act_id",
]
)
):
@@ -166,6 +196,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var saved_clock_accumulator := float(simulation_data["clock_accumulator"])
var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"])
var saved_next_opportunity_id := int(simulation_data["next_opportunity_id"])
var saved_next_situation_id := int(simulation_data["next_situation_id"])
var saved_next_journal_entry_id := int(simulation_data["next_journal_entry_id"])
var saved_next_commitment_id := int(simulation_data["next_commitment_id"])
var saved_next_conversation_act_id := int(simulation_data["next_conversation_act_id"])
if (
not is_finite(saved_tick_interval)
or saved_tick_interval <= 0.0
@@ -174,6 +208,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
or saved_clock_accumulator < 0.0
or saved_clock_elapsed_ticks < 0
or saved_next_opportunity_id < 0
or saved_next_situation_id < 0
or saved_next_journal_entry_id < 0
or saved_next_commitment_id < 0
or saved_next_conversation_act_id < 0
):
return null
if simulation_data.has("cycle_duration_seconds"):
@@ -204,6 +242,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var relationship_data = record_data["relationships"]
var knowledge_data = record_data["event_knowledge"]
var opportunity_data = record_data["opportunities"]
var situation_data = record_data["situations"]
var journal_data = record_data["quest_journal"]
var commitment_data = record_data["commitments"]
var conversation_history_data = record_data["conversation_history"]
if (
not npc_data is Array
or not animal_data is Array
@@ -213,6 +255,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
or not relationship_data is Array
or not knowledge_data is Array
or not opportunity_data is Array
or not situation_data is Array
or not journal_data is Array
or not commitment_data is Array
or not conversation_history_data is Array
):
return null
@@ -425,7 +471,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return null
var knower_id := known_event_record.get_knower_id()
var known_event_id := known_event_record.get_event_id()
if not npc_ids.has(knower_id) or not event_ids.has(known_event_id):
if (
(knower_id != SimulationIds.PLAYER_ACTOR_ID and not npc_ids.has(knower_id))
or not event_ids.has(known_event_id)
):
return null
var knowledge_key := "%d:%d" % [knower_id, known_event_id]
if knowledge_keys.has(knowledge_key):
@@ -448,29 +497,28 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return null
var observer_id := relationship_record.get_observer_id()
var subject_id := relationship_record.get_subject_id()
var is_player_observer := observer_id == SimulationIds.PLAYER_ACTOR_ID
var is_player_subject := subject_id == SimulationIds.PLAYER_ACTOR_ID
if not npc_ids.has(observer_id):
if not is_player_observer and not npc_ids.has(observer_id):
return null
if not is_player_subject and not npc_ids.has(subject_id):
return null
var relationship_key := "%d:%d" % [observer_id, subject_id]
if relationship_keys.has(relationship_key):
return null
var cause_event_id := relationship_record.get_last_trust_cause_event_id()
if (
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
and not event_ids.has(cause_event_id)
):
return null
if (
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
and not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id])
):
return null
if cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT:
var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord
if not _is_valid_relationship_cause(cause_event, subject_id, is_player_subject):
for dimension in RelationshipStateRecord.DIMENSIONS:
var cause_event_id := relationship_record.get_last_cause_event_id(dimension)
if cause_event_id == RelationshipStateRecord.NO_CAUSE_EVENT:
continue
if (
not event_ids.has(cause_event_id)
or not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id])
):
return null
if dimension == RelationshipStateRecord.DIMENSION_TRUST:
var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord
if not _is_valid_relationship_cause(cause_event, subject_id, is_player_subject):
return null
relationship_keys[relationship_key] = true
record.relationships.append(relationship_record)
@@ -515,6 +563,36 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id:
return null
var emergent_records: Variant = EmergentStateValidator.parse(
situation_data, journal_data, commitment_data, simulation_data, npc_ids, event_ids
)
if emergent_records == null:
return null
record.situations.assign(emergent_records["situations"])
record.quest_journal.assign(emergent_records["quest_journal"])
record.commitments.assign(emergent_records["commitments"])
var conversation_act_ids: Dictionary = {}
var highest_conversation_act_id := -1
for item in conversation_history_data:
if not item is Dictionary:
return null
var conversation_act := ConversationActStateRecord.from_dictionary(item)
if (
conversation_act == null
or conversation_act_ids.has(conversation_act.get_act_id())
or conversation_act.get_tick() > saved_tick_count
or not EmergentStateValidator.is_valid_actor(conversation_act.get_speaker(), npc_ids)
or not EmergentStateValidator.is_valid_actor(conversation_act.get_listener(), npc_ids)
):
return null
conversation_act_ids[conversation_act.get_act_id()] = true
highest_conversation_act_id = maxi(
highest_conversation_act_id, conversation_act.get_act_id()
)
record.conversation_history.append(conversation_act)
if saved_next_conversation_act_id <= highest_conversation_act_id:
return null
return record
@@ -579,16 +657,21 @@ static func _is_valid_knowledge_provenance(
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
if knower_id == actor_id:
return false
var source_npc_id := known_event.get_source_npc_id()
if not npc_ids.has(source_npc_id):
var source_actor_id := known_event.get_source_actor_id()
if (
source_actor_id != SimulationIds.PLAYER_ACTOR_ID
and not npc_ids.has(source_actor_id)
):
return false
var source_method := known_event.get_source_acquisition_method()
if (
(source_method == SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
!= (source_npc_id == actor_id)
!= (source_actor_id == actor_id)
):
return false
return true
if source_method not in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS:
return false
return known_event.get_hop_count() == 1
return false
@@ -774,12 +857,13 @@ static func _is_valid_home_damaged_trigger(
static func _is_valid_relationship_cause(
cause_event: EconomicEventRecord, subject_id: int, is_player_subject: bool
) -> bool:
var event_type := StringName(cause_event.data["event_type"])
if EmergentStateValidator.is_valid_commitment_relationship_cause(cause_event, subject_id):
return true
if not is_player_subject:
return (
int(cause_event.data["actor_id"]) == subject_id
and (
StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
)
and (event_type == SimulationIds.EVENT_STORAGE_DEPOSITED)
and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and float(cause_event.data["amount"]) > 0.0
)
@@ -789,7 +873,7 @@ static func _is_valid_relationship_cause(
return false
if float(cause_event.data["amount"]) <= 0.0:
return false
if StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED:
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
return (
StringName(cause_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID
and (
@@ -798,7 +882,7 @@ static func _is_valid_relationship_cause(
)
)
return (
StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED
event_type == SimulationIds.EVENT_RESOURCE_EXTRACTED
and StringName(cause_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
)
@@ -848,6 +932,16 @@ static func _is_valid_supply_resolution(
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION
migrated["situations"] = []
migrated["quest_journal"] = []
migrated["commitments"] = []
migrated["conversation_history"] = []
var emergent_simulation_data: Dictionary = migrated.get("simulation", {})
emergent_simulation_data["next_situation_id"] = 0
emergent_simulation_data["next_journal_entry_id"] = 0
emergent_simulation_data["next_commitment_id"] = 0
emergent_simulation_data["next_conversation_act_id"] = 0
migrated["simulation"] = emergent_simulation_data
if not migrated.has("player"):
migrated["player"] = PlayerStateRecord.create_default().to_dictionary()
if version < CONFLICT_SCHEMA_VERSION:
@@ -927,6 +1021,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
PLAYER_NEEDS_SCHEMA_VERSION,
CONFLICT_SCHEMA_VERSION,
PRE_EMERGENT_SCHEMA_VERSION,
]
):
migrated["opportunities"] = []
+5 -1
View File
@@ -28,6 +28,10 @@ static func create(
return null
if trigger_event_id < NO_EVENT_ID:
return null
var normalized_context := context.duplicate(true)
for integer_key in ["event_id", "opportunity_id"]:
if normalized_context.has(integer_key):
normalized_context[integer_key] = int(normalized_context[integer_key])
return (
SituationStateRecord
. new(
@@ -43,7 +47,7 @@ static func create(
"resolution_event_id": NO_EVENT_ID,
"closed_tick": -1,
"close_reason": "",
"context": context.duplicate(true),
"context": normalized_context,
}
)
)
@@ -0,0 +1,255 @@
class_name EmergentStateValidator
extends RefCounted
static func parse(
situation_data: Array,
journal_data: Array,
commitment_data: Array,
simulation_data: Dictionary,
npc_ids: Dictionary,
event_ids: Dictionary
) -> Variant:
var current_tick := int(simulation_data["tick_count"])
var next_situation_id := int(simulation_data["next_situation_id"])
var next_journal_entry_id := int(simulation_data["next_journal_entry_id"])
var next_commitment_id := int(simulation_data["next_commitment_id"])
var situation_system := SituationSystem.create_default()
var situations_by_id: Dictionary = {}
var situations: Array[SituationStateRecord] = []
if not _parse_situations(
situation_data,
current_tick,
next_situation_id,
npc_ids,
event_ids,
situation_system,
situations_by_id,
situations
):
return null
var journal: Array[QuestJournalEntryStateRecord] = []
if not _parse_journal(
journal_data,
current_tick,
next_journal_entry_id,
situation_system,
situations_by_id,
journal
):
return null
var commitments: Array[CommitmentStateRecord] = []
if not _parse_commitments(
commitment_data,
current_tick,
next_commitment_id,
npc_ids,
event_ids,
situation_system,
situations_by_id,
commitments
):
return null
return {
"situations": situations,
"quest_journal": journal,
"commitments": commitments,
}
static func _parse_situations(
serialized: Array,
current_tick: int,
next_id: int,
npc_ids: Dictionary,
event_ids: Dictionary,
system: SituationSystem,
by_id: Dictionary,
result: Array[SituationStateRecord]
) -> bool:
var highest_id := -1
for item in serialized:
if not item is Dictionary:
return false
var record := SituationStateRecord.from_dictionary(item)
if record == null:
return false
var situation_id := record.get_situation_id()
var definition := system.get_definition(record.get_definition_id())
if by_id.has(situation_id) or definition == null:
return false
if (
record.get_created_tick() > current_tick
or (not record.is_active() and record.get_closed_tick() > current_tick)
):
return false
var trigger_event_id := record.get_trigger_event_id()
if (
trigger_event_id != SituationStateRecord.NO_EVENT_ID
and not event_ids.has(trigger_event_id)
):
return false
var resolution_event_id := record.get_resolution_event_id()
if (
resolution_event_id != SituationStateRecord.NO_EVENT_ID
and (not event_ids.has(resolution_event_id) or resolution_event_id <= trigger_event_id)
):
return false
var alternative_id := record.get_selected_alternative_id()
if not alternative_id.is_empty() and definition.get_alternative(alternative_id) == null:
return false
var context := record.get_context()
if context.has("interested_npc_id"):
var interested_id_text := String(context["interested_npc_id"])
if (
not interested_id_text.is_valid_int()
or not npc_ids.has(interested_id_text.to_int())
):
return false
by_id[situation_id] = record
highest_id = maxi(highest_id, situation_id)
result.append(record)
if next_id <= highest_id:
return false
return system.restore(result, next_id)
static func _parse_journal(
serialized: Array,
current_tick: int,
next_id: int,
situation_system: SituationSystem,
situations_by_id: Dictionary,
result: Array[QuestJournalEntryStateRecord]
) -> bool:
var ids: Dictionary = {}
var situation_ids: Dictionary = {}
var highest_id := -1
for item in serialized:
if not item is Dictionary:
return false
var record := QuestJournalEntryStateRecord.from_dictionary(item)
if record == null:
return false
var entry_id := record.get_entry_id()
var situation := situations_by_id.get(record.get_situation_id()) as SituationStateRecord
if (
ids.has(entry_id)
or situation_ids.has(record.get_situation_id())
or situation == null
or record.get_definition_id() != situation.get_definition_id()
or record.get_discovered_tick() < situation.get_created_tick()
or record.get_discovered_tick() > current_tick
or (not record.is_active() and record.get_closed_tick() > current_tick)
):
return false
var definition := situation_system.get_definition(record.get_definition_id())
var alternative_id := record.get_selected_alternative_id()
if (
not alternative_id.is_empty()
and (
definition == null
or definition.get_alternative(alternative_id) == null
or situation.get_selected_alternative_id() != alternative_id
)
):
return false
ids[entry_id] = true
situation_ids[record.get_situation_id()] = true
highest_id = maxi(highest_id, entry_id)
result.append(record)
if next_id <= highest_id:
return false
return QuestJournalSystem.new().restore(result, next_id)
static func _parse_commitments(
serialized: Array,
current_tick: int,
next_id: int,
npc_ids: Dictionary,
event_ids: Dictionary,
situation_system: SituationSystem,
situations_by_id: Dictionary,
result: Array[CommitmentStateRecord]
) -> bool:
var ids: Dictionary = {}
var highest_id := -1
for item in serialized:
if not item is Dictionary:
return false
var record := CommitmentStateRecord.from_dictionary(item)
if record == null:
return false
var commitment_id := record.get_commitment_id()
var situation := situations_by_id.get(record.get_situation_id()) as SituationStateRecord
if (
ids.has(commitment_id)
or situation == null
or record.get_created_tick() < situation.get_created_tick()
or record.get_created_tick() > current_tick
or (not record.is_active() and record.get_closed_tick() > current_tick)
or not is_valid_actor(record.get_debtor(), npc_ids)
or not is_valid_actor(record.get_creditor(), npc_ids)
):
return false
var definition := situation_system.get_definition(situation.get_definition_id())
var alternative: SituationAlternativeDefinition
if definition != null:
alternative = definition.get_alternative(situation.get_selected_alternative_id())
if alternative == null or alternative.commitment_terms != record.get_terms():
return false
var cause_event_id := record.get_cause_event_id()
if (
cause_event_id != CommitmentStateRecord.NO_EVENT_ID
and not event_ids.has(cause_event_id)
):
return false
ids[commitment_id] = true
highest_id = maxi(highest_id, commitment_id)
result.append(record)
if next_id <= highest_id:
return false
return SocialCommitmentSystem.new().restore(result, next_id)
static func is_valid_actor(entity: WorldEntityRef, npc_ids: Dictionary) -> bool:
if entity == null or not entity.is_valid():
return false
if entity.get_entity_type() == WorldEventRecord.ENTITY_TYPE_PLAYER:
return String(entity.get_entity_id()) == str(SimulationIds.PLAYER_ACTOR_ID)
if entity.get_entity_type() != WorldEventRecord.ENTITY_TYPE_NPC:
return false
var entity_id_text := String(entity.get_entity_id())
return entity_id_text.is_valid_int() and npc_ids.has(entity_id_text.to_int())
static func is_valid_commitment_relationship_cause(
cause_event: EconomicEventRecord, subject_id: int
) -> bool:
if cause_event == null:
return false
if (
StringName(cause_event.data["event_type"])
not in [
SimulationIds.EVENT_COMMITMENT_FULFILLED,
SimulationIds.EVENT_COMMITMENT_BROKEN,
]
):
return false
var debtor_data = cause_event.data.get("debtor")
if not debtor_data is Dictionary:
return false
var debtor := WorldEntityRef.from_dictionary(debtor_data)
if debtor == null:
return false
var expected_type := (
WorldEventRecord.ENTITY_TYPE_PLAYER
if subject_id == SimulationIds.PLAYER_ACTOR_ID
else WorldEventRecord.ENTITY_TYPE_NPC
)
return (
debtor.get_entity_type() == expected_type
and String(debtor.get_entity_id()) == str(subject_id)
and int(cause_event.data["actor_id"]) == subject_id
)
@@ -0,0 +1 @@
uid://bolivdac21erj