feat: unify authored activity commands

This commit is contained in:
Rijad Zuzo
2026-08-13 00:08:30 +02:00
parent e5aecee0de
commit b3b98c6456
36 changed files with 3013 additions and 96 deletions
+666 -5
View File
@@ -86,6 +86,11 @@ var latest_decisions: Dictionary = {}
var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new()
var activity_command_service: ActivityActionCommandService
var interaction_service: CatalogInteractionService
var _abstract_activity_registry: WorldTargetRegistry
var _abstract_activity_command_service: ActivityActionCommandService
var _active_activity_registry_instance_id := 0
var last_player_resource_query_stats: Dictionary = {}
var _population_view := SimulationPopulationView.new()
var speed_index := 2
@@ -129,6 +134,7 @@ func _ready() -> void:
push_error("SimulationManager: " + error)
set_process(false)
return
_configure_interaction_services()
clock = SimulationClock.new(tick_interval)
clock.cycle_duration_seconds = cycle_duration_seconds
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
@@ -395,11 +401,21 @@ func _complete_current_action(npc: SimNPC) -> void:
var completed_task := npc.current_task
var definition := SimulationDefinitions.get_action(completed_task)
var completion_succeeded := false
if completed_task == SimulationIds.ACTION_FEED_ANIMAL:
var completion_already_applied := false
if _uses_activity_command_handler(definition):
var activity_result := execute_activity_action(
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
completed_task,
npc.target_id,
get_action_state_revision()
)
completion_succeeded = activity_result != null and activity_result.did_succeed()
completion_already_applied = completion_succeeded
elif completed_task == SimulationIds.ACTION_FEED_ANIMAL:
completion_succeeded = feed_animal(npc.target_id, npc.id)
else:
completion_succeeded = economy.consume_completion_cost(npc, definition)
if completion_succeeded:
if completion_succeeded and not completion_already_applied:
_apply_action_completion(npc, completed_task, definition)
elif not npc.target_id.is_empty():
release_npc_reservation(npc.id)
@@ -436,7 +452,7 @@ func _apply_action_completion(
record_narrative_event(SimulationIds.EVENT_HOME_DAMAGED, npc.id)
SimulationIds.ACTION_FEED_ANIMAL:
pass
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
village.apply_npc_task(npc)
SimulationIds.ACTION_DEFEND:
village.apply_metric_delta(&"safety", 0.5)
@@ -619,12 +635,24 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
var definition := SimulationDefinitions.get_action(npc.current_task)
if _uses_activity_command_handler(definition) and not _ensure_interaction_services_current():
return false
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
if result.is_empty():
notify_npc_navigation_failed(npc.id)
return false
npc.target_id = StringName(result.get("target_id", ""))
npc.travel_target_position = result["position"]
if (
definition != null
and _uses_activity_command_handler(definition)
and not _bind_abstract_activity_target(
npc.target_id, npc.current_task, npc.travel_target_position
)
):
notify_npc_navigation_failed(npc.id)
return false
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
@@ -661,6 +689,612 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int =
return count
func get_action_state_revision() -> int:
return event_log.next_event_id
func get_player_activity_offer(from_position: Vector3, max_distance: float) -> Dictionary:
if not _ensure_interaction_services_current():
return {}
if (
interaction_service == null
or active_world_adapter == null
or not from_position.is_finite()
or not is_finite(max_distance)
or max_distance <= 0.0
or not active_world_adapter.has_method("get_target_registry")
):
return {}
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
if registry == null:
return {}
var actor := WorldEntityRef.create(
SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
)
var candidates: Array[Dictionary] = []
for descriptor in registry.get_descriptors(ActiveWorldAdapter.TARGET_KIND_ACTIVITY):
var distance := from_position.distance_to(descriptor.get_local_position())
if distance > max_distance:
continue
var handle := registry.get_handle(descriptor.get_target_id())
for offer in interaction_service.get_offers(actor, handle):
var definition := SimulationDefinitions.get_action(offer.get_action_id())
if not _uses_activity_command_handler(definition):
continue
var capability := descriptor.get_capability(offer.get_action_id())
var raw_range: Variant = capability.get_attribute(&"interaction_range", 0.0)
if raw_range is not int and raw_range is not float:
continue
var capability_range := float(raw_range)
if (
not is_finite(capability_range)
or capability_range <= 0.0
or distance > capability_range
):
continue
(
candidates
. append(
{
"action_id": String(offer.get_action_id()),
"target_id": String(descriptor.get_target_id()),
"display_name": descriptor.get_display_name(),
"action_name": offer.get_display_name(),
"distance": distance,
"priority": offer.get_priority(),
"state_revision": get_action_state_revision(),
"target_generation": handle.get_generation(),
"target_context_id": String(handle.get_context_id()),
"target_registry_instance_id": registry.get_instance_id(),
"target_kind": String(handle.get_target_kind()),
"effect": _player_activity_effect_summary(definition),
"enabled": offer.is_enabled(),
"blocked_reason": offer.get_rejection_reason(),
"offer_parameters": offer.get_parameters(),
}
)
)
if candidates.is_empty():
return {}
candidates.sort_custom(_activity_offer_precedes)
return candidates[0].duplicate(true)
func execute_activity_action(
actor: WorldEntityRef,
action_id: StringName,
target_id: StringName,
expected_state_revision: int = -1
) -> ActionResult:
if (
actor == null
or not actor.is_valid()
or actor.get_entity_type() != SimulationIds.ENTITY_PERSON
or expected_state_revision < 0
):
return ActionResult.rejected(&"activity_invalid_actor", &"invalid_actor")
return _execute_authorized_activity_action(actor, action_id, target_id, expected_state_revision)
func _execute_authorized_activity_action(
actor: WorldEntityRef,
action_id: StringName,
target_id: StringName,
expected_state_revision: int
) -> ActionResult:
if not _ensure_interaction_services_current():
return ActionResult.rejected(&"activity_scope_mismatch", &"service_unavailable")
var selected_service := activity_command_service
var handle: WorldTargetHandle
if actor.get_entity_type() == SimulationIds.ENTITY_PERSON:
var npc_id_text := String(actor.get_entity_id())
var npc := _find_npc_by_id(npc_id_text.to_int()) if npc_id_text.is_valid_int() else null
if (
npc == null
or npc.is_dead
or npc.task_state != SimNPC.TASK_STATE_COMPLETE
or not npc.task_complete
or npc.current_task != action_id
or npc.target_id.is_empty()
or npc.target_id != target_id
):
return ActionResult.rejected(
&"activity_assignment_mismatch",
ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR
)
selected_service = _abstract_activity_command_service
handle = _ensure_abstract_activity_handle(action_id, target_id)
elif active_world_adapter != null and active_world_adapter.has_method("get_target_registry"):
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
handle = registry.get_handle(target_id) if registry != null else null
if selected_service == null or handle == null:
return ActionResult.rejected(&"activity_unavailable", &"service_unavailable")
var actor_key := actor.index_key() if actor != null else "invalid"
var command_revision := get_action_state_revision()
var command_id := StringName(
(
"activity_%s_%s_%s_%d_%d"
% [
actor_key.sha256_text().substr(0, 8),
action_id,
target_id,
tick_count,
command_revision,
]
)
)
return selected_service.submit_command(
ActionCommand.new(command_id, actor, action_id, handle, {}, expected_state_revision)
)
func execute_player_activity_action(
action_id: StringName,
target_id: StringName,
expected_state_revision: int = -1,
expected_target_generation: int = -1,
expected_context_id: StringName = &"",
expected_registry_instance_id: int = 0
) -> ActionResult:
if not _ensure_interaction_services_current():
return ActionResult.rejected(&"player_activity_stale", &"service_unavailable")
if (
expected_state_revision < 0
or expected_target_generation < 1
or expected_context_id.is_empty()
or expected_registry_instance_id == 0
or active_world_adapter == null
or not active_world_adapter.has_method("get_target_registry")
):
return ActionResult.rejected(
&"player_activity_stale",
ActivityActionCommandService.REASON_STALE_TARGET,
"The activity offer is incomplete; refresh it"
)
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
var current_handle := registry.get_handle(target_id) if registry != null else null
if (
current_handle == null
or registry.get_instance_id() != expected_registry_instance_id
or current_handle.get_generation() != expected_target_generation
or current_handle.get_context_id() != expected_context_id
or current_handle.get_target_kind() != SimulationIds.TARGET_ACTIVITY
):
return ActionResult.rejected(
&"player_activity_stale",
ActivityActionCommandService.REASON_STALE_TARGET,
"The activity changed; refresh the interaction offer"
)
return _execute_authorized_activity_action(
WorldEntityRef.create(
SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
),
action_id,
target_id,
expected_state_revision
)
func _configure_interaction_services() -> bool:
activity_command_service = null
interaction_service = null
_active_activity_registry_instance_id = 0
var current_scope := event_log.get_scope()
var scope_world_id := StringName(
current_scope.get("world_id", SimulationIds.REGIONAL_WORLD_BOSNIA)
)
var scope_location_id := StringName(
current_scope.get("location_id", SimulationIds.REGIONAL_LOCATION_JAJCE)
)
if active_world_adapter != null and active_world_adapter.has_method("get_context_identity"):
var identity: Dictionary = active_world_adapter.call("get_context_identity")
scope_world_id = StringName(identity.get("world_id", scope_world_id))
scope_location_id = StringName(identity.get("location_id", scope_location_id))
if not event_log.configure_scope(scope_world_id, scope_location_id):
return false
_abstract_activity_registry = WorldTargetRegistry.new(
&"simulation_jajce", scope_world_id, scope_location_id
)
_abstract_activity_command_service = ActivityActionCommandService.new(
_abstract_activity_registry,
SimulationDefinitions.get_action,
_get_activity_actor_position,
get_action_state_revision,
_complete_activity_command,
false,
false
)
register_abstract_activity_target(
SimulationIds.ACTIVITY_GUARD_POST, SimulationIds.ACTION_PATROL, Vector3.ZERO
)
register_abstract_activity_target(
SimulationIds.ACTIVITY_STUDY_DESK, SimulationIds.ACTION_STUDY, Vector3.ZERO
)
_rebuild_abstract_activity_targets_from_npcs()
var catalog := ContentCatalog.create_core()
if not catalog.is_valid():
return false
if active_world_adapter == null or not active_world_adapter.has_method("get_target_registry"):
return true
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
if registry == null:
return false
activity_command_service = ActivityActionCommandService.new(
registry,
SimulationDefinitions.get_action,
_get_activity_actor_position,
get_action_state_revision,
_complete_activity_command,
true
)
interaction_service = CatalogInteractionService.new(
registry, catalog, _evaluate_activity_availability, true
)
_active_activity_registry_instance_id = registry.get_instance_id()
return true
func register_abstract_activity_target(
target_id: StringName, action_id: StringName, position: Vector3, interaction_range: float = 0.5
) -> bool:
if (
_abstract_activity_registry == null
or target_id.is_empty()
or not position.is_finite()
or not is_finite(interaction_range)
or interaction_range <= 0.0
or _abstract_activity_registry.has_target(target_id)
):
return false
var definition := SimulationDefinitions.get_action(action_id)
if not _uses_activity_command_handler(definition):
return false
var scope := _abstract_activity_registry.get_identity()
var handle := (
_abstract_activity_registry
. register_target(
(
WorldTargetDescriptor
. new(
target_id,
ActiveWorldAdapter.TARGET_KIND_ACTIVITY,
[
WorldTargetCapability.new(
action_id, {"interaction_range": interaction_range}
)
],
position,
definition.display_name,
{
"authority": "simulation",
"world_id": String(scope["world_id"]),
"location_id": String(scope["location_id"]),
}
)
)
)
)
return handle != null
func _bind_abstract_activity_target(
target_id: StringName, action_id: StringName, position: Vector3
) -> bool:
if _abstract_activity_registry == null or target_id.is_empty() or not position.is_finite():
return false
var existing := _abstract_activity_registry.get_handle(target_id)
if existing == null:
return register_abstract_activity_target(target_id, action_id, position)
var descriptor := _abstract_activity_registry.resolve_handle(existing)
if (
descriptor == null
or descriptor.get_target_kind() != SimulationIds.TARGET_ACTIVITY
or not descriptor.has_capability(action_id)
):
return false
return _abstract_activity_registry.update_target_position(existing, position)
func _rebuild_abstract_activity_targets_from_npcs() -> void:
for npc in npcs:
if npc.is_dead or npc.target_id.is_empty():
continue
var definition := SimulationDefinitions.get_action(npc.current_task)
if not _uses_activity_command_handler(definition):
continue
_bind_abstract_activity_target(npc.target_id, npc.current_task, npc.travel_target_position)
func _ensure_interaction_services_current() -> bool:
if active_world_adapter == null or not active_world_adapter.has_method("get_target_registry"):
return _abstract_activity_command_service != null
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
if registry == null:
return false
var identity := registry.get_identity()
var event_scope := event_log.get_scope()
if (
StringName(identity["world_id"]) != StringName(event_scope["world_id"])
or StringName(identity["location_id"]) != StringName(event_scope["location_id"])
):
if not economic_events.is_empty():
activity_command_service = null
interaction_service = null
return false
if registry.get_instance_id() == _active_activity_registry_instance_id:
return activity_command_service != null and _abstract_activity_command_service != null
return _configure_interaction_services()
func _ensure_abstract_activity_handle(
action_id: StringName, requested_target_id: StringName
) -> WorldTargetHandle:
if _abstract_activity_registry == null:
return null
var target_id := requested_target_id
if target_id.is_empty():
var candidates := _abstract_activity_registry.get_target_ids(
ActiveWorldAdapter.TARGET_KIND_ACTIVITY, action_id
)
if candidates.size() != 1:
return null
target_id = candidates[0]
var existing := _abstract_activity_registry.get_handle(target_id)
if existing == null:
return null
var descriptor := _abstract_activity_registry.resolve_handle(existing)
if descriptor == null or not descriptor.has_capability(action_id):
return null
return _abstract_activity_registry.get_handle(target_id)
func _evaluate_activity_availability(
actor: WorldEntityRef,
_descriptor: WorldTargetDescriptor,
definition: ActionDefinition,
_capability: WorldTargetCapability
) -> InteractionAvailabilityDecision:
if not _uses_activity_command_handler(definition):
return InteractionAvailabilityDecision.denied(
&"unsupported_handler",
"This activity has no supported completion handler",
[&"handler_missing"]
)
var effect := ActivityActionCommandService._metric_effect(definition)
var actor_id := _activity_actor_id(actor)
if effect == null or actor_id < SimulationIds.PLAYER_ACTOR_ID:
return InteractionAvailabilityDecision.denied(
&"invalid_effect", "This activity has no valid effect", [&"effect_rejected"]
)
var requested_amount := _activity_effect_amount(actor_id, effect.value, effect.parameters)
if _bounded_activity_effect_amount(effect.subject_key, requested_amount) <= 0.0:
return InteractionAvailabilityDecision.denied(
&"no_effect", "This activity cannot improve the village further", [&"effect_capped"]
)
if definition.has_completion_cost():
var storage := economy.get_storage_for_resource(definition.completion_cost_resource_id)
var available := (
storage.get_amount(definition.completion_cost_resource_id) if storage != null else 0.0
)
if available < definition.completion_cost_amount:
return InteractionAvailabilityDecision.denied(
&"insufficient_cost",
(
"Needs %.0f %s"
% [definition.completion_cost_amount, definition.completion_cost_resource_id]
),
[&"cost_rejected"],
{"available": available, "required": definition.completion_cost_amount}
)
return InteractionAvailabilityDecision.allowed([&"cost_allowed"])
func _get_activity_actor_position(actor: WorldEntityRef) -> Variant:
var actor_id := _activity_actor_id(actor)
if actor_id == SimulationIds.PLAYER_ACTOR_ID:
var combatant := conflict_system.get_combatant(ConflictSystemScript.PLAYER_COMBATANT_ID)
return combatant.get_position() if combatant != null else null
var npc := _find_npc_by_id(actor_id)
return npc.position if npc != null and not npc.is_dead else null
func _complete_activity_command(
actor: WorldEntityRef,
action_id: StringName,
target_id: StringName,
metric_id: StringName,
base_amount: float,
effect_parameters: Dictionary,
world_position: Vector3
) -> Dictionary:
var actor_id := _activity_actor_id(actor)
var definition := SimulationDefinitions.get_action(action_id)
if actor_id < SimulationIds.PLAYER_ACTOR_ID or not _uses_activity_command_handler(definition):
return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()}
var requested_amount := _activity_effect_amount(actor_id, base_amount, effect_parameters)
var applied_amount := _bounded_activity_effect_amount(metric_id, requested_amount)
if not is_finite(applied_amount) or applied_amount <= 0.0:
return {
"succeeded": false,
"reason_code": "no_effect",
"message": "This activity cannot improve the village further",
"event_id": -1,
"state_revision": get_action_state_revision(),
}
var cost_storage: StorageStateRecord
var cost_item_id: StringName
var cost_amount := 0.0
if definition.has_completion_cost():
cost_item_id = definition.completion_cost_resource_id
cost_amount = definition.completion_cost_amount
cost_storage = economy.get_storage_for_resource(cost_item_id)
var available := cost_storage.get_amount(cost_item_id) if cost_storage != null else 0.0
if available < cost_amount:
var blocked := event_log.record_activity_blocked(
tick_count,
actor_id,
target_id,
action_id,
(
"%s: needs %.0f %s"
% [definition.display_name, cost_amount, String(cost_item_id).capitalize()]
),
world_position,
cost_storage.get_storage_id() if cost_storage != null else &"",
cost_item_id,
cost_amount
)
return {
"succeeded": false,
"reason_code": "insufficient_cost",
"message": "Needs %.0f %s" % [cost_amount, cost_item_id],
"event_id": int(blocked.data["event_id"]) if blocked != null else -1,
"state_revision": get_action_state_revision(),
}
var previous_metric := village.safety if metric_id == &"safety" else village.knowledge
if cost_storage != null and cost_storage.withdraw(cost_item_id, cost_amount) < cost_amount:
return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()}
if cost_storage != null:
economy.sync_resource(cost_item_id)
village.apply_metric_delta(metric_id, applied_amount)
var actual_amount := (
village.safety - previous_metric
if metric_id == &"safety"
else village.knowledge - previous_metric
)
if not is_finite(actual_amount) or actual_amount <= 0.0:
if cost_storage != null:
cost_storage.deposit(cost_item_id, cost_amount)
economy.sync_resource(cost_item_id)
_restore_village_metric(metric_id, previous_metric)
return {
"succeeded": false,
"reason_code": "no_effect",
"message": "This activity cannot improve the village further",
"event_id": -1,
"state_revision": get_action_state_revision(),
}
var event := event_log.record_activity_completed(
tick_count,
actor_id,
target_id,
action_id,
definition.event_type_id,
metric_id,
actual_amount,
world_position,
cost_item_id,
cost_amount
)
if event == null:
if cost_storage != null:
cost_storage.deposit(cost_item_id, cost_amount)
economy.sync_resource(cost_item_id)
_restore_village_metric(metric_id, previous_metric)
return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()}
village_changed.emit(village)
return {
"succeeded": true,
"event_id": int(event.data["event_id"]),
"state_revision": get_action_state_revision(),
"applied_amount": actual_amount,
}
func _activity_actor_id(actor: WorldEntityRef) -> int:
if actor == null or not actor.is_valid():
return SimulationIds.PLAYER_ACTOR_ID - 1
var id_text := String(actor.get_entity_id())
if not id_text.is_valid_int():
return SimulationIds.PLAYER_ACTOR_ID - 1
var actor_id := id_text.to_int()
if actor.get_entity_type() == SimulationIds.ENTITY_PLAYER:
return (
actor_id
if (
actor_id == SimulationIds.PLAYER_ACTOR_ID
and player_system.player_state != null
and not player_system.player_state.is_downed()
)
else -2
)
if actor.get_entity_type() != SimulationIds.ENTITY_PERSON or actor_id < 0:
return -2
var npc := _find_npc_by_id(actor_id)
return actor_id if npc != null and not npc.is_dead else -2
func _activity_effect_amount(actor_id: int, base_amount: float, parameters: Dictionary) -> float:
var result := base_amount
if actor_id == SimulationIds.PLAYER_ACTOR_ID:
var raw_multiplier: Variant = parameters.get("player_multiplier", 1.0)
if raw_multiplier is int or raw_multiplier is float:
result *= float(raw_multiplier)
return result
if bool(parameters.get("npc_productivity", false)):
var npc := _find_npc_by_id(actor_id)
if npc == null:
return 0.0
if npc.is_starving:
result *= 0.35
elif npc.energy < 20.0:
result *= 0.25
elif npc.energy < 40.0:
result *= 0.5
return result
func _player_activity_effect_summary(definition: ActionDefinition) -> Dictionary:
if definition == null:
return {}
var effect := ActivityActionCommandService._metric_effect(definition)
if effect == null:
return {}
var requested_amount := _activity_effect_amount(
SimulationIds.PLAYER_ACTOR_ID, effect.value, effect.parameters
)
return {
"metric_id": String(effect.subject_key),
"amount": _bounded_activity_effect_amount(effect.subject_key, requested_amount),
}
func _bounded_activity_effect_amount(metric_id: StringName, requested_amount: float) -> float:
if not is_finite(requested_amount) or requested_amount <= 0.0:
return 0.0
if metric_id == &"safety":
return minf(requested_amount, maxf(100.0 - village.safety, 0.0))
if metric_id == &"knowledge":
return requested_amount
return 0.0
func _restore_village_metric(metric_id: StringName, value: float) -> void:
if metric_id == &"safety":
village.safety = value
elif metric_id == &"knowledge":
village.knowledge = value
village.update_modifiers()
village.update_priorities()
static func _uses_activity_command_handler(definition: ActionDefinition) -> bool:
return (
definition != null
and definition.completion_handler_id == ActivityActionCommandService.HANDLER_METRIC_DELTA
)
static func _activity_offer_precedes(first: Dictionary, second: Dictionary) -> bool:
if not is_equal_approx(float(first["distance"]), float(second["distance"])):
return float(first["distance"]) < float(second["distance"])
if float(first["priority"]) != float(second["priority"]):
return float(first["priority"]) > float(second["priority"])
if String(first["target_id"]) != String(second["target_id"]):
return String(first["target_id"]) < String(second["target_id"])
return String(first["action_id"]) < String(second["action_id"])
func _notify_mourning(dead_npc: SimNPC) -> void:
var mourner := relationship_system.get_most_familiar_living(dead_npc.id, npcs, _population_view)
if mourner == null:
@@ -1653,6 +2287,7 @@ func get_latest_decision(npc_id: int) -> ActionSelectionResult:
func create_state_record() -> SimulationStateRecord:
var record := SimulationStateRecord.new()
var event_scope := event_log.get_scope()
var wander_streams: Array[Dictionary] = []
var sorted_npc_ids: Array = wander_random_sources.keys()
sorted_npc_ids.sort()
@@ -1674,6 +2309,8 @@ func create_state_record() -> SimulationStateRecord:
"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,
"world_id": String(event_scope["world_id"]),
"location_id": String(event_scope["location_id"]),
"cycle_duration_seconds": clock.cycle_duration_seconds
}
record.village = VillageStateRecord.capture(village)
@@ -1724,7 +2361,7 @@ func restore_state_from_json(json_text: String) -> bool:
func restore_state(record: SimulationStateRecord) -> bool:
if record == null:
if record == null or not _saved_scope_matches_active_adapter(record):
return false
simulation_seed = int(record.simulation["seed"])
tick_interval = float(record.simulation["tick_interval"])
@@ -1740,12 +2377,20 @@ func restore_state(record: SimulationStateRecord) -> bool:
# Rebuild them before the next decision so save/load cannot change outcomes.
village.update_modifiers()
village.update_priorities()
event_log.restore(record.economic_events, int(record.simulation["next_event_id"]))
if not event_log.restore(
record.economic_events,
int(record.simulation["next_event_id"]),
StringName(record.simulation["world_id"]),
StringName(record.simulation["location_id"])
):
return false
latest_decisions.clear()
npcs.clear()
for npc_record in record.npcs:
npcs.append(npc_record.restore(debug_logs))
refresh_population_index()
if not _configure_interaction_services():
return false
relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge)
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
@@ -1798,3 +2443,19 @@ func restore_state(record: SimulationStateRecord) -> bool:
village_changed.emit(village)
state_restored.emit()
return true
func _saved_scope_matches_active_adapter(record: SimulationStateRecord) -> bool:
if active_world_adapter == null or not active_world_adapter.has_method("get_context_identity"):
return true
var identity: Dictionary = active_world_adapter.call("get_context_identity")
return (
(
StringName(identity.get("world_id", &""))
== StringName(record.simulation.get("world_id", &""))
)
and (
StringName(identity.get("location_id", &""))
== StringName(record.simulation.get("location_id", &""))
)
)