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", &""))
)
)
+2 -2
View File
@@ -73,9 +73,9 @@ static func accepted(command_id: StringName, payload: Dictionary = {}) -> Action
static func rejected(
command_id: StringName, reason_code: StringName, message: String = ""
command_id: StringName, reason_code: StringName, message: String = "", payload: Dictionary = {}
) -> ActionResult:
return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message)
return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message, payload)
static func completed(command_id: StringName, payload: Dictionary = {}) -> ActionResult:
@@ -0,0 +1,265 @@
class_name ActivityActionCommandService
extends ActionCommandService
const HANDLER_METRIC_DELTA := SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA
const ACTOR_KIND_PLAYER := SimulationIds.ENTITY_PLAYER
const ACTOR_KIND_PERSON := SimulationIds.ENTITY_PERSON
const REASON_INVALID_COMMAND := &"invalid_command"
const REASON_STALE_TARGET := &"stale_target"
const REASON_STALE_REVISION := &"stale_revision"
const REASON_UNSUPPORTED_ACTION := &"unsupported_action"
const REASON_UNSUPPORTED_ACTOR := &"unsupported_actor"
const REASON_TARGET_CAPABILITY := &"target_capability_missing"
const REASON_OUT_OF_RANGE := &"out_of_range"
const REASON_INVALID_EFFECT := &"invalid_effect"
const REASON_COMPLETION_FAILED := &"completion_failed"
var _registry: WorldTargetRegistry
var _definition_lookup: Callable
var _actor_position_lookup: Callable
var _state_revision_lookup: Callable
var _completion_handler: Callable
var _effect_planner := ActionEffectPlanner.new()
var _require_target_provider := false
var _enforce_spatial_range := true
func _init(
registry: WorldTargetRegistry = null,
definition_lookup: Callable = Callable(),
actor_position_lookup: Callable = Callable(),
state_revision_lookup: Callable = Callable(),
completion_handler: Callable = Callable(),
require_target_provider: bool = false,
enforce_spatial_range: bool = true
) -> void:
_registry = registry
_definition_lookup = definition_lookup
_actor_position_lookup = actor_position_lookup
_state_revision_lookup = state_revision_lookup
_completion_handler = completion_handler
_require_target_provider = require_target_provider
_enforce_spatial_range = enforce_spatial_range
func try_execute(command: ActionCommand) -> ActionResult:
var result := _execute(command)
command_resolved.emit(result)
return result
func _execute(command: ActionCommand) -> ActionResult:
if command == null or not command.is_valid() or not _services_are_valid():
return _reject(command, REASON_INVALID_COMMAND, "Command or service wiring is invalid")
var definition := _definition_lookup.call(command.get_action_id()) as ActionDefinition
if (
definition == null
or definition.target_type != SimulationIds.TARGET_ACTIVITY
or definition.completion_handler_id != HANDLER_METRIC_DELTA
):
return _reject(command, REASON_UNSUPPORTED_ACTION, "Action has no activity metric handler")
var actor := command.get_actor()
if actor.get_entity_type() not in [ACTOR_KIND_PLAYER, ACTOR_KIND_PERSON]:
return _reject(command, REASON_UNSUPPORTED_ACTOR, "Actor kind cannot perform activity work")
var effect_plan := _effect_planner.plan(definition, actor)
var metric_operation := _metric_operation(effect_plan)
if metric_operation.is_empty():
return _reject(command, REASON_INVALID_EFFECT, "Action metric effect plan is invalid")
var handle := command.get_target()
if not _registry.is_handle_valid(handle, _require_target_provider):
return _reject(command, REASON_STALE_TARGET, "Activity target is no longer available")
var descriptor := _registry.resolve_handle(handle)
if (
handle.get_target_kind() != SimulationIds.TARGET_ACTIVITY
or descriptor == null
or descriptor.get_target_kind() != SimulationIds.TARGET_ACTIVITY
or not descriptor.has_capability(command.get_action_id())
):
return _reject(command, REASON_TARGET_CAPABILITY, "Activity target lacks the action")
var current_revision := int(_state_revision_lookup.call())
if (
command.get_expected_state_revision() >= 0
and command.get_expected_state_revision() != current_revision
):
return _reject(command, REASON_STALE_REVISION, "Authoritative state revision changed")
var capability := descriptor.get_capability(command.get_action_id())
var actor_position: Variant = _actor_position_lookup.call(actor)
if actor_position is not Vector3 or not actor_position.is_finite():
return _reject(command, REASON_UNSUPPORTED_ACTOR, "Actor has no valid world position")
if _enforce_spatial_range:
var raw_maximum_range: Variant = capability.get_attribute(&"interaction_range", 0.0)
if raw_maximum_range is not int and raw_maximum_range is not float:
return _reject(command, REASON_INVALID_EFFECT, "Activity range metadata is invalid")
var maximum_range := float(raw_maximum_range)
if (
not is_finite(maximum_range)
or maximum_range <= 0.0
or actor_position.distance_to(descriptor.get_local_position()) > maximum_range
):
return _reject(command, REASON_OUT_OF_RANGE, "Actor is outside the activity range")
var effect_payload: Dictionary = metric_operation["payload"]
var metric_id := StringName(effect_payload["subject_key"])
var amount := float(effect_payload["delta"])
var completion: Variant = _completion_handler.call(
actor,
command.get_action_id(),
descriptor.get_target_id(),
metric_id,
amount,
(effect_payload["parameters"] as Dictionary).duplicate(true),
actor_position
)
if not _completion_shape_is_valid(completion):
return ActionResult.failed(
command.get_command_id(),
REASON_COMPLETION_FAILED,
"Authoritative activity completion returned an invalid result"
)
var completion_result := completion as Dictionary
if not bool(completion_result["succeeded"]):
var failure_reason := StringName(completion_result.get("reason_code", ""))
var failure_message := String(completion_result.get("message", ""))
if failure_reason.is_empty() or failure_message.is_empty():
return ActionResult.failed(
command.get_command_id(),
REASON_COMPLETION_FAILED,
"Authoritative activity completion was rejected"
)
var failure_event_id := int(completion_result["event_id"])
var failure_revision := int(completion_result["state_revision"])
var failure_payload := {}
if failure_event_id >= 0:
if failure_event_id != current_revision or failure_revision < current_revision + 1:
return ActionResult.failed(
command.get_command_id(),
REASON_COMPLETION_FAILED,
"Authoritative failure fact did not advance state"
)
failure_payload = {
"event_id": failure_event_id,
"state_revision": failure_revision,
"reason_trace": ["command_rejected", "authoritative_failure_fact_recorded"],
}
elif failure_revision != current_revision:
return ActionResult.failed(
command.get_command_id(),
REASON_COMPLETION_FAILED,
"Rejected completion mutated state without a failure fact"
)
return ActionResult.rejected(
command.get_command_id(), failure_reason, failure_message, failure_payload
)
var event_id := int(completion_result["event_id"])
var resulting_revision := int(completion_result["state_revision"])
var applied_amount := float(completion_result.get("applied_amount", amount))
if (
event_id != current_revision
or resulting_revision < current_revision + 1
or not is_finite(applied_amount)
):
return ActionResult.failed(
command.get_command_id(),
REASON_COMPLETION_FAILED,
"Authoritative activity completion did not advance state"
)
var spatial_trace := (
"range_revalidated" if _enforce_spatial_range else "abstract_completion_authorized"
)
return (
ActionResult
. completed(
command.get_command_id(),
{
"action_id": String(command.get_action_id()),
"target_id": String(descriptor.get_target_id()),
"metric_id": String(metric_id),
"amount": applied_amount,
"event_id": event_id,
"state_revision": resulting_revision,
"reason_trace":
[
"command_shape_valid",
"actor_authorized",
"target_handle_live",
"target_capability_matched",
"revision_matched",
spatial_trace,
"authoritative_metric_mutated",
"completion_fact_recorded",
"causal_followup_facts_committed",
],
}
)
)
func _services_are_valid() -> bool:
return (
_registry != null
and _definition_lookup.is_valid()
and _actor_position_lookup.is_valid()
and _state_revision_lookup.is_valid()
and _completion_handler.is_valid()
)
static func _metric_effect(definition: ActionDefinition) -> ActionEffect:
if definition == null or definition.effects.size() != 1:
return null
var selected: ActionEffect
for resource in definition.effects:
var effect := resource as ActionEffect
if effect == null or effect.effect_kind != ActionEffect.KIND_METRIC_DELTA:
continue
if selected != null:
return null
selected = effect
if selected == null:
return null
if (
selected.subject_key not in [&"safety", &"knowledge"]
or not is_finite(selected.value)
or selected.value <= 0.0
):
return null
for raw_key in selected.parameters:
if String(raw_key) not in ["player_multiplier", "npc_productivity"]:
return null
var player_multiplier: Variant = selected.parameters.get("player_multiplier", 1.0)
if (
(player_multiplier is not int and player_multiplier is not float)
or not is_finite(float(player_multiplier))
or float(player_multiplier) <= 0.0
):
return null
if selected.parameters.get("npc_productivity", false) is not bool:
return null
return selected
static func _metric_operation(plan: ActionEffectPlan) -> Dictionary:
if plan == null or not plan.is_valid() or plan.get_operation_count() != 1:
return {}
var operation: Dictionary = plan.get_operations()[0]
if StringName(operation.get("operation_kind", &"")) != ActionEffect.KIND_METRIC_DELTA:
return {}
return operation.duplicate(true)
static func _completion_shape_is_valid(completion: Variant) -> bool:
if completion is not Dictionary:
return false
var result := completion as Dictionary
if not result.has_all(["succeeded", "event_id", "state_revision"]):
return false
return (
result["succeeded"] is bool
and result["event_id"] is int
and result["state_revision"] is int
and not WorldTargetCapability._contains_object(result)
)
static func _reject(command: ActionCommand, reason: StringName, message: String) -> ActionResult:
var command_id := command.get_command_id() if command != null else &"invalid_command"
return ActionResult.rejected(command_id, reason, message)
@@ -0,0 +1 @@
uid://ce4eqg4gbqcf8
@@ -17,16 +17,19 @@ var _registry: WorldTargetRegistry
var _catalog: ContentCatalog
var _availability_evaluator: Callable
var _last_query_reason_trace: Array[StringName] = []
var _require_target_provider := false
func _init(
registry: WorldTargetRegistry = null,
catalog: ContentCatalog = null,
availability_evaluator: Callable = Callable()
availability_evaluator: Callable = Callable(),
require_target_provider: bool = false
) -> void:
_registry = registry
_catalog = catalog
_availability_evaluator = availability_evaluator
_require_target_provider = require_target_provider
func is_configured() -> bool:
@@ -49,7 +52,7 @@ func get_offers(actor: WorldEntityRef, target: WorldTargetHandle) -> Array[Actio
if not _actor_is_supported(actor):
_last_query_reason_trace.append(REASON_ACTOR_INVALID)
return []
if not _registry.is_handle_valid(target):
if not _registry.is_handle_valid(target, _require_target_provider):
_last_query_reason_trace.append(REASON_TARGET_STALE)
return []
var descriptor := _registry.resolve_handle(target)
+78 -1
View File
@@ -1,5 +1,6 @@
class_name ContentCatalog
extends RefCounted
# gdlint: disable=max-file-lines
const CORE_PACK_PATH := "res://simulation/definitions/packs/core.tres"
const DEFAULT_SITUATION_EVENT_PREDICATE_IDS: Array[StringName] = [
@@ -11,6 +12,7 @@ const DEFAULT_SITUATION_STATE_PREDICATE_IDS: Array[StringName] = [
const DEFAULT_SITUATION_EXPIRY_PREDICATE_IDS: Array[StringName] = [
&"state_number_gt", &"age_reached"
]
const BUILTIN_HANDLER_IDS: Array[StringName] = [SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA]
var _valid := false
var _errors: Array[String] = []
@@ -81,11 +83,14 @@ func rebuild(
var pack_list := content_packs.duplicate()
pack_list.sort_custom(_sort_packs)
var supported_handlers := {}
for handler_id in BUILTIN_HANDLER_IDS:
supported_handlers[handler_id] = true
for handler_id in supported_handler_ids:
if handler_id.is_empty():
errors.append("Supported handler ID is empty")
elif supported_handlers.has(handler_id):
errors.append("Duplicate supported handler '%s'" % handler_id)
if handler_id not in BUILTIN_HANDLER_IDS:
errors.append("Duplicate supported handler '%s'" % handler_id)
else:
supported_handlers[handler_id] = true
var cue_ids := (
@@ -202,7 +207,9 @@ func rebuild(
actions_by_id,
professions_by_id,
items_by_id,
storages_by_id,
capability_tags_by_id,
event_types_by_id,
supported_handlers,
supported_cues,
errors
@@ -670,7 +677,9 @@ static func _validate_action_references(
actions_by_id: Dictionary,
professions_by_id: Dictionary,
items_by_id: Dictionary,
storages_by_id: Dictionary,
capability_tags_by_id: Dictionary,
event_types_by_id: Dictionary,
supported_handlers: Dictionary,
supported_cues: Dictionary,
errors: Array[String]
@@ -696,6 +705,16 @@ static func _validate_action_references(
supported_cues,
errors
)
if (
not definition.event_type_id.is_empty()
and not event_types_by_id.has(definition.event_type_id)
):
errors.append(
(
"Action '%s' references unknown event type '%s'"
% [definition.action_id, definition.event_type_id]
)
)
for handler_field in [
["selection policy", definition.selection_policy_id],
["target policy", definition.target_policy_id],
@@ -769,6 +788,64 @@ static func _validate_action_references(
% [definition.action_id, scheduled_action_id]
)
)
_validate_builtin_action_handler_contract(definition, items_by_id, storages_by_id, errors)
static func _validate_builtin_action_handler_contract(
definition: ActionDefinition,
items_by_id: Dictionary,
storages_by_id: Dictionary,
errors: Array[String]
) -> void:
if definition.completion_handler_id != SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA:
return
var label := "Action '%s' activity_metric_delta" % definition.action_id
if definition.target_type != SimulationIds.TARGET_ACTIVITY:
errors.append("%s handler requires an activity target" % label)
if definition.effects.size() != 1:
errors.append("%s handler requires exactly one effect" % label)
return
var effect := definition.effects[0] as ActionEffect
if effect == null or effect.effect_kind != ActionEffect.KIND_METRIC_DELTA:
errors.append("%s handler requires one metric_delta effect" % label)
return
if effect.subject_key not in [&"safety", &"knowledge"]:
errors.append("%s metric must be safety or knowledge" % label)
if not is_finite(effect.value) or effect.value <= 0.0:
errors.append("%s effect value must be finite and positive" % label)
var allowed_parameters := {"player_multiplier": true, "npc_productivity": true}
for raw_key in effect.parameters:
var key := String(raw_key)
if not allowed_parameters.has(key):
errors.append("%s has unsupported effect parameter '%s'" % [label, key])
var player_multiplier: Variant = effect.parameters.get("player_multiplier", 1.0)
if player_multiplier is not int and player_multiplier is not float:
errors.append("%s player_multiplier must be numeric" % label)
elif not is_finite(float(player_multiplier)) or float(player_multiplier) <= 0.0:
errors.append("%s player_multiplier must be finite and positive" % label)
var npc_productivity: Variant = effect.parameters.get("npc_productivity", false)
if npc_productivity is not bool:
errors.append("%s npc_productivity must be a bool" % label)
if definition.event_type_id.is_empty():
errors.append("%s handler requires a nonempty event_type_id" % label)
if not definition.has_completion_cost():
return
var cost_item := items_by_id.get(definition.completion_cost_resource_id) as ItemDefinition
if cost_item == null:
return
var has_storage_route := false
for storage_value in storages_by_id.values():
var storage := storage_value as StorageDefinition
if storage != null and storage.accepts_item(cost_item):
has_storage_route = true
break
if not has_storage_route:
errors.append(
(
"%s completion cost item '%s' has no authored storage route"
% [label, definition.completion_cost_resource_id]
)
)
static func _validate_item_references(
@@ -28,6 +28,7 @@ const PROFESSION_PATHS := [
static var _action_list: Array[ActionDefinition] = []
static var _profession_list: Array[ProfessionDefinition] = []
static var _action_map: Dictionary = {}
static var _action_event_type_ids: Dictionary = {}
static var _profession_map: Dictionary = {}
static var _profession_id_list: Array[StringName] = []
static var _catalog_errors: Array[String] = []
@@ -40,6 +41,7 @@ static func _ensure_cache() -> void:
_action_list = []
_profession_list = []
_action_map = {}
_action_event_type_ids = {}
_profession_map = {}
_profession_id_list = []
_catalog_errors = []
@@ -52,6 +54,8 @@ static func _ensure_cache() -> void:
_profession_list = catalog.get_professions()
for definition in _action_list:
_action_map[definition.action_id] = definition
if not definition.event_type_id.is_empty():
_action_event_type_ids[definition.event_type_id] = true
for definition in _profession_list:
_profession_map[definition.profession_id] = definition
_profession_id_list.append(definition.profession_id)
@@ -78,6 +82,11 @@ static func get_action(action_id: StringName) -> ActionDefinition:
return _action_map.get(action_id)
static func has_action_event_type(event_type_id: StringName) -> bool:
_ensure_cache()
return _action_event_type_ids.has(event_type_id)
static func get_profession(profession_id: StringName) -> ProfessionDefinition:
_ensure_cache()
return _profession_map.get(profession_id)
+8
View File
@@ -6,6 +6,13 @@ const PLAYER_INVENTORY_ID := &"player_inventory"
const WORLD_CORE := &"world_core"
const LOCATION_JAJCE := &"settlement_jajce"
const REGIONAL_WORLD_BOSNIA := &"regional_bosnia"
const REGIONAL_LOCATION_JAJCE := &"location_jajce"
const ACTIVITY_GUARD_POST := &"guard_post"
const ACTIVITY_STUDY_DESK := &"study_desk"
const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta"
const BLOCKED_CONTRACT_ACTION_COST := &"action_cost"
const BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED := &"legacy_unstructured"
const ENTITY_PERSON := &"person"
const ENTITY_PLAYER := &"player"
@@ -72,6 +79,7 @@ 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 EVENT_ACTIVITY_COMPLETED := &"activity_completed"
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
+13 -1
View File
@@ -1,6 +1,15 @@
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
[ext_resource type="Script" path="res://simulation/commands/ActionEffect.gd" id="2"]
[sub_resource type="Resource" id="Effect_patrol_safety"]
script = ExtResource("2")
effect_id = &"patrol_safety"
effect_kind = &"metric_delta"
subject_key = &"safety"
value = 1.0
parameters = {"npc_productivity": true, "player_multiplier": 3.0}
[resource]
script = ExtResource("1")
@@ -11,3 +20,6 @@ preferred_profession_id = &"guard"
target_type = &"activity"
completion_cost_resource_id = &"wood"
completion_cost_amount = 1.0
completion_handler_id = &"activity_metric_delta"
effects = [SubResource("Effect_patrol_safety")]
event_type_id = &"activity_completed"
+13 -1
View File
@@ -1,6 +1,15 @@
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
[ext_resource type="Script" path="res://simulation/commands/ActionEffect.gd" id="2"]
[sub_resource type="Resource" id="Effect_study_knowledge"]
script = ExtResource("2")
effect_id = &"study_knowledge"
effect_kind = &"metric_delta"
subject_key = &"knowledge"
value = 1.0
parameters = {"npc_productivity": true, "player_multiplier": 2.0}
[resource]
script = ExtResource("1")
@@ -11,3 +20,6 @@ preferred_profession_id = &"scholar"
target_type = &"activity"
completion_cost_resource_id = &"wood"
completion_cost_amount = 1.0
completion_handler_id = &"activity_metric_delta"
effects = [SubResource("Effect_study_knowledge")]
event_type_id = &"activity_completed"
@@ -0,0 +1,10 @@
[gd_resource type="Resource" script_class="EventTypeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/EventTypeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
event_type_id = &"activity_completed"
display_name = "Activity Completed"
description = "An actor completed a world-backed activity and changed authoritative state."
category = "activity"
+4 -2
View File
@@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=93 format=3]
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=94 format=3]
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
@@ -81,6 +81,7 @@
[ext_resource type="Resource" path="res://simulation/definitions/event_types/war_resolved.tres" id="79_war_resolved"]
[ext_resource type="Resource" path="res://simulation/definitions/event_types/war_aborted.tres" id="80_war_aborted"]
[ext_resource type="Resource" path="res://simulation/definitions/event_types/wolf_hunt.tres" id="81_wolf_hunt"]
[ext_resource type="Resource" path="res://simulation/definitions/event_types/activity_completed.tres" id="92_activity_completed"]
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/storage_food_aid_trust.tres" id="82_storage_food_aid"]
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/player_food_aid_trust.tres" id="83_player_food_aid"]
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_accepted_obligation.tres" id="84_accepted_obligation"]
@@ -96,6 +97,7 @@
script = ExtResource("1_pack")
pack_id = &"core"
display_name = "The Steward Core"
required_handler_ids = [&"activity_metric_delta"]
actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")]
professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")]
capability_tags = [ExtResource("28_item_food"), ExtResource("29_item_medicine"), ExtResource("30_item_wood"), ExtResource("31_resource_harvestable"), ExtResource("32_storage_village")]
@@ -107,5 +109,5 @@ animals = [ExtResource("39_goat"), ExtResource("40_sheep")]
situations = [ExtResource("41_pantry_situation")]
dialogue_intents = [ExtResource("42_accept"), ExtResource("43_acknowledge"), ExtResource("44_wellbeing"), ExtResource("45_happened"), ExtResource("46_who"), ExtResource("47_work"), ExtResource("48_decline"), ExtResource("49_need"), ExtResource("50_goodbye"), ExtResource("51_greet"), ExtResource("52_offer"), ExtResource("53_renegotiate"), ExtResource("54_progress"), ExtResource("55_reproach"), ExtResource("56_share"), ExtResource("57_thank")]
dialogue_template_catalogs = [ExtResource("58_templates")]
event_types = [ExtResource("59_resource_extracted"), ExtResource("60_storage_deposited"), ExtResource("61_storage_withdrawn"), ExtResource("62_item_consumed"), ExtResource("63_npc_slept"), ExtResource("64_npc_died"), ExtResource("65_task_started"), ExtResource("66_task_blocked"), ExtResource("67_resource_depleted"), ExtResource("68_animal_fed"), ExtResource("69_villager_weak"), ExtResource("70_home_damaged"), ExtResource("71_commitment_accepted"), ExtResource("72_commitment_fulfilled"), ExtResource("73_commitment_broken"), ExtResource("74_commitment_released"), ExtResource("75_commitment_superseded"), ExtResource("76_combatant_hurt"), ExtResource("77_combatant_killed"), ExtResource("78_raid_started"), ExtResource("79_war_resolved"), ExtResource("80_war_aborted"), ExtResource("81_wolf_hunt")]
event_types = [ExtResource("59_resource_extracted"), ExtResource("60_storage_deposited"), ExtResource("61_storage_withdrawn"), ExtResource("62_item_consumed"), ExtResource("63_npc_slept"), ExtResource("64_npc_died"), ExtResource("65_task_started"), ExtResource("66_task_blocked"), ExtResource("67_resource_depleted"), ExtResource("68_animal_fed"), ExtResource("69_villager_weak"), ExtResource("70_home_damaged"), ExtResource("71_commitment_accepted"), ExtResource("72_commitment_fulfilled"), ExtResource("73_commitment_broken"), ExtResource("74_commitment_released"), ExtResource("75_commitment_superseded"), ExtResource("76_combatant_hurt"), ExtResource("77_combatant_killed"), ExtResource("78_raid_started"), ExtResource("79_war_resolved"), ExtResource("80_war_aborted"), ExtResource("81_wolf_hunt"), ExtResource("92_activity_completed")]
social_consequences = [ExtResource("82_storage_food_aid"), ExtResource("83_player_food_aid"), ExtResource("84_accepted_obligation"), ExtResource("85_fulfilled_obligation"), ExtResource("86_fulfilled_trust"), ExtResource("87_broken_obligation"), ExtResource("88_broken_trust"), ExtResource("89_broken_hostility"), ExtResource("90_released_obligation"), ExtResource("91_superseded_obligation")]
+64 -17
View File
@@ -22,26 +22,43 @@ signal narrative_event_requested(
var village: SimVillage
var storage_states: Dictionary = {}
var debug_logs := false
var content_catalog: ContentCatalog
var storage_routing := StorageRoutingPolicy.new()
var _storage_id_by_item_id: Dictionary = {}
func configure(village_state: SimVillage, should_debug: bool) -> void:
func configure(
village_state: SimVillage, should_debug: bool, configured_catalog: ContentCatalog = null
) -> void:
village = village_state
debug_logs = should_debug
content_catalog = (
configured_catalog if configured_catalog != null else ContentCatalog.create_core()
)
storage_routing = StorageRoutingPolicy.new(content_catalog)
_storage_id_by_item_id.clear()
func initialize_storage() -> void:
if village == null:
return
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY,
{String(SimulationIds.RESOURCE_FOOD): village.food}
)
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_WOODPILE,
{String(SimulationIds.RESOURCE_WOOD): village.wood}
)
if content_catalog != null and content_catalog.is_valid():
for definition in content_catalog.get_storages():
if storage_states.has(definition.storage_id):
continue
var initial_amounts := {}
if definition.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY:
initial_amounts[String(SimulationIds.RESOURCE_FOOD)] = village.food
elif definition.storage_id == SimulationIds.STORAGE_VILLAGE_WOODPILE:
initial_amounts[String(SimulationIds.RESOURCE_WOOD)] = village.wood
var initial_total := 0.0
for amount in initial_amounts.values():
initial_total += float(amount)
storage_states[definition.storage_id] = StorageStateRecord.create(
definition.storage_id, initial_amounts, maxf(definition.capacity, initial_total)
)
_ensure_legacy_village_storage()
_rebuild_storage_route_cache()
sync_all()
@@ -67,12 +84,42 @@ func get_woodpile() -> StorageStateRecord:
func get_storage_for_resource(resource_id: StringName) -> StorageStateRecord:
match resource_id:
SimulationIds.RESOURCE_FOOD:
return get_pantry()
SimulationIds.RESOURCE_WOOD:
return get_woodpile()
return null
var storage_id := StringName(_storage_id_by_item_id.get(resource_id, &""))
return get_storage(storage_id) if not storage_id.is_empty() else null
func _ensure_legacy_village_storage() -> void:
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY,
{String(SimulationIds.RESOURCE_FOOD): village.food},
maxf(StorageStateRecord.DEFAULT_CAPACITY, village.food)
)
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_WOODPILE,
{String(SimulationIds.RESOURCE_WOOD): village.wood},
maxf(StorageStateRecord.DEFAULT_CAPACITY, village.wood)
)
func _rebuild_storage_route_cache() -> void:
_storage_id_by_item_id.clear()
if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
_storage_id_by_item_id[SimulationIds.RESOURCE_FOOD] = (SimulationIds.STORAGE_VILLAGE_PANTRY)
if storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
_storage_id_by_item_id[SimulationIds.RESOURCE_WOOD] = (
SimulationIds.STORAGE_VILLAGE_WOODPILE
)
if content_catalog == null or not content_catalog.is_valid():
return
var available_ids: Array[StringName] = []
for storage_key in storage_states:
available_ids.append(StringName(storage_key))
for item in content_catalog.get_items():
var definition := storage_routing.resolve_definition(item.item_id, available_ids)
if definition != null:
_storage_id_by_item_id[item.item_id] = definition.storage_id
func sync_all() -> void:
+293
View File
@@ -0,0 +1,293 @@
class_name ActivityEventValidator
extends RefCounted
static var _core_catalog: ContentCatalog
static var _core_storage_routing: StorageRoutingPolicy
static func is_candidate(event: EconomicEventRecord) -> bool:
if event == null:
return false
if StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED:
return false
if StringName(event.data.get("target_kind", &"")) == SimulationIds.TARGET_ACTIVITY:
return true
var event_type := StringName(event.data.get("event_type", &""))
return SimulationDefinitions.has_action_event_type(event_type)
static func is_valid(
event: EconomicEventRecord,
expected_world_id: StringName = &"",
expected_location_id: StringName = &""
) -> bool:
if event == null or not _has_activity_fields(event):
return false
var actor_id := int(event.data["actor_id"])
var action_id := StringName(event.data["action_id"])
var definition := SimulationDefinitions.get_action(action_id)
var effect := ActivityActionCommandService._metric_effect(definition)
if (
not KnownEventStateRecord.is_valid_actor_id(actor_id)
or definition == null
or effect == null
or definition.target_type != SimulationIds.TARGET_ACTIVITY
or definition.completion_handler_id != ActivityActionCommandService.HANDLER_METRIC_DELTA
or StringName(event.data["event_type"]) != definition.event_type_id
or StringName(event.data["source_id"]).is_empty()
or not StringName(event.data["destination_id"]).is_empty()
or StringName(event.data["target_kind"]) != SimulationIds.TARGET_ACTIVITY
or StringName(event.data["target_action_id"]) != action_id
or StringName(event.data["world_id"]).is_empty()
or StringName(event.data["location_id"]).is_empty()
or (
not expected_world_id.is_empty()
and StringName(event.data["world_id"]) != expected_world_id
)
or (
not expected_location_id.is_empty()
and StringName(event.data["location_id"]) != expected_location_id
)
):
return false
var metric_delta_value: Variant = event.data["metric_delta"]
var cost_amount_value: Variant = event.data["cost_amount"]
if (
(metric_delta_value is not int and metric_delta_value is not float)
or (cost_amount_value is not int and cost_amount_value is not float)
):
return false
var metric_delta := float(metric_delta_value)
var cost_amount := float(cost_amount_value)
var maximum_delta := _maximum_delta(effect, actor_id)
return (
is_finite(metric_delta)
and metric_delta > 0.0
and is_finite(maximum_delta)
and maximum_delta > 0.0
and (metric_delta < maximum_delta or is_equal_approx(metric_delta, maximum_delta))
and is_finite(cost_amount)
and StringName(event.data["metric_id"]) == effect.subject_key
and StringName(event.data["cost_item_id"]) == definition.completion_cost_resource_id
and StringName(event.data["item_id"]) == definition.completion_cost_resource_id
and is_equal_approx(cost_amount, definition.completion_cost_amount)
and is_equal_approx(float(event.data["required_amount"]), definition.completion_cost_amount)
)
static func is_valid_for_state(
event: EconomicEventRecord,
npc_ids: Dictionary,
current_tick: int,
expected_world_id: StringName = &"",
expected_location_id: StringName = &""
) -> bool:
if (
not is_valid(event, expected_world_id, expected_location_id)
or int(event.data["tick"]) > current_tick
):
return false
var actor_id := int(event.data["actor_id"])
return actor_id == SimulationIds.PLAYER_ACTOR_ID or npc_ids.has(actor_id)
static func is_blocked_candidate(event: EconomicEventRecord) -> bool:
return (
event != null
and StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED
)
static func is_valid_blocked(
event: EconomicEventRecord,
expected_world_id: StringName = &"",
expected_location_id: StringName = &""
) -> bool:
if event == null or not _has_blocked_fields(event):
return false
if is_legacy_unstructured_blocked(event):
return _is_valid_legacy_unstructured_blocked(event, expected_world_id, expected_location_id)
var actor_id := int(event.data["actor_id"])
var action_id := StringName(event.data["action_id"])
var definition := SimulationDefinitions.get_action(action_id)
if (
StringName(event.data["event_type"]) != SimulationIds.EVENT_TASK_BLOCKED
or not KnownEventStateRecord.is_valid_actor_id(actor_id)
or definition == null
or not definition.has_completion_cost()
or (
StringName(event.data["blocked_contract_kind"])
!= SimulationIds.BLOCKED_CONTRACT_ACTION_COST
)
or StringName(event.data["item_id"]) != definition.completion_cost_resource_id
or not is_equal_approx(
float(event.data["required_amount"]), definition.completion_cost_amount
)
or StringName(event.data["source_id"]).is_empty()
or not StringName(event.data["destination_id"]).is_empty()
or not is_zero_approx(float(event.data["amount"]))
or StringName(event.data["world_id"]).is_empty()
or StringName(event.data["location_id"]).is_empty()
or (
not expected_world_id.is_empty()
and StringName(event.data["world_id"]) != expected_world_id
)
or (
not expected_location_id.is_empty()
and StringName(event.data["location_id"]) != expected_location_id
)
):
return false
var route := _get_core_storage_routing().resolve_definition(
definition.completion_cost_resource_id
)
if route == null or not _blocked_source_matches(event, action_id, actor_id, route.storage_id):
return false
if event.data.has("activity_target_id"):
return (
definition.target_type == SimulationIds.TARGET_ACTIVITY
and (
definition.completion_handler_id
== SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA
)
and not StringName(event.data["activity_target_id"]).is_empty()
and StringName(event.data.get("target_kind", &"")) == SimulationIds.TARGET_ACTIVITY
and StringName(event.data.get("target_action_id", &"")) == action_id
)
return true
static func _blocked_source_matches(
event: EconomicEventRecord, action_id: StringName, actor_id: int, routed_storage_id: StringName
) -> bool:
var source_id := StringName(event.data["source_id"])
if source_id == routed_storage_id:
return true
return (
action_id == SimulationIds.ACTION_FEED_ANIMAL
and actor_id >= 0
and source_id == SimulationIds.npc_inventory_id(actor_id)
)
static func is_valid_blocked_for_state(
event: EconomicEventRecord,
npc_ids: Dictionary,
storage_records_by_id: Dictionary,
current_tick: int,
expected_world_id: StringName = &"",
expected_location_id: StringName = &""
) -> bool:
if (
not is_valid_blocked(event, expected_world_id, expected_location_id)
or int(event.data["tick"]) > current_tick
):
return false
var actor_id := int(event.data["actor_id"])
if actor_id != SimulationIds.PLAYER_ACTOR_ID and not npc_ids.has(actor_id):
return false
if is_legacy_unstructured_blocked(event):
return true
var source_id := StringName(event.data["source_id"])
return (
storage_records_by_id.has(source_id)
or (
StringName(event.data["action_id"]) == SimulationIds.ACTION_FEED_ANIMAL
and actor_id >= 0
and source_id == SimulationIds.npc_inventory_id(actor_id)
)
)
static func is_legacy_unstructured_blocked(event: EconomicEventRecord) -> bool:
return (
event != null
and StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED
and (
StringName(event.data.get("blocked_contract_kind", &""))
== SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED
)
)
static func _is_valid_legacy_unstructured_blocked(
event: EconomicEventRecord, expected_world_id: StringName, expected_location_id: StringName
) -> bool:
var legacy_version := int(event.data.get("legacy_world_schema_version", -1))
return (
(
legacy_version
in range(
EconomicEventRecord.LEGACY_BLOCKED_WORLD_SCHEMA_MIN,
EconomicEventRecord.LEGACY_BLOCKED_WORLD_SCHEMA_MAX + 1
)
)
and KnownEventStateRecord.is_valid_actor_id(int(event.data["actor_id"]))
and StringName(event.data["action_id"]).is_empty()
and StringName(event.data["item_id"]).is_empty()
and is_zero_approx(float(event.data["required_amount"]))
and StringName(event.data["destination_id"]).is_empty()
and is_zero_approx(float(event.data["amount"]))
and not StringName(event.data["world_id"]).is_empty()
and not StringName(event.data["location_id"]).is_empty()
and (
expected_world_id.is_empty() or StringName(event.data["world_id"]) == expected_world_id
)
and (
expected_location_id.is_empty()
or StringName(event.data["location_id"]) == expected_location_id
)
)
static func _has_activity_fields(event: EconomicEventRecord) -> bool:
return (
event
. data
. has_all(
[
"metric_id",
"metric_delta",
"cost_item_id",
"cost_amount",
"target_kind",
"target_action_id",
"world_id",
"location_id",
]
)
)
static func _has_blocked_fields(event: EconomicEventRecord) -> bool:
return (
event
. data
. has_all(
[
"blocked_contract_kind",
"world_id",
"location_id",
"action_id",
"item_id",
"required_amount",
]
)
)
static func _maximum_delta(effect: ActionEffect, actor_id: int) -> float:
var maximum_delta := effect.value
if actor_id != SimulationIds.PLAYER_ACTOR_ID:
return maximum_delta
var raw_multiplier: Variant = effect.parameters.get("player_multiplier", 1.0)
if raw_multiplier is not int and raw_multiplier is not float:
return -1.0
return maximum_delta * float(raw_multiplier)
static func _get_core_storage_routing() -> StorageRoutingPolicy:
if _core_storage_routing == null:
_core_catalog = ContentCatalog.create_core()
_core_storage_routing = StorageRoutingPolicy.new(_core_catalog)
return _core_storage_routing
@@ -0,0 +1 @@
uid://spr5p8ebsyc1
+142 -6
View File
@@ -28,6 +28,23 @@ var _events_by_id: Dictionary = {}
var _event_ids_by_actor: Dictionary = {}
var _event_ids_by_type: Dictionary = {}
var _event_ids_by_tick: Dictionary = {}
var _world_id := SimulationIds.REGIONAL_WORLD_BOSNIA
var _location_id := SimulationIds.REGIONAL_LOCATION_JAJCE
func configure_scope(world_id: StringName, location_id: StringName) -> bool:
if world_id.is_empty() or location_id.is_empty():
return false
if not events.is_empty():
return world_id == _world_id and location_id == _location_id
_world_id = world_id
_location_id = location_id
world_events = WorldEventStore.new()
return true
func get_scope() -> Dictionary:
return {"world_id": _world_id, "location_id": _location_id}
func record_economic(
@@ -80,6 +97,14 @@ func record_narrative(
item_id,
required_amount
)
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
event.data["blocked_contract_kind"] = String(SimulationIds.BLOCKED_CONTRACT_ACTION_COST)
event.data["world_id"] = String(_world_id)
event.data["location_id"] = String(_location_id)
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
if normalized == null:
return null
event = normalized
_append(event)
return event
@@ -115,11 +140,105 @@ func record_fact(
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
if normalized == null:
return null
if WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null:
return null
_append(normalized)
return normalized
func record_activity_completed(
tick: int,
actor_id: int,
target_id: StringName,
action_id: StringName,
event_type_id: StringName,
metric_id: StringName,
metric_delta: float,
world_position: Vector3 = Vector3.ZERO,
cost_item_id: StringName = &"",
cost_amount: float = 0.0
) -> EconomicEventRecord:
if (
WorldEventRecord.from_economic_event(
normalized, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE
)
== null
tick < 0
or actor_id < SimulationIds.PLAYER_ACTOR_ID
or target_id.is_empty()
or action_id.is_empty()
or event_type_id.is_empty()
or metric_id not in [&"safety", &"knowledge"]
or not is_finite(metric_delta)
or metric_delta <= 0.0
or not world_position.is_finite()
or not is_finite(cost_amount)
or cost_amount < 0.0
or (cost_item_id.is_empty() and cost_amount > 0.0)
or (not cost_item_id.is_empty() and cost_amount <= 0.0)
):
return null
var event := EconomicEventRecord.create_narrative(
next_event_id,
event_type_id,
tick,
actor_id,
target_id,
"",
world_position,
action_id,
cost_item_id,
cost_amount
)
event.data["metric_id"] = String(metric_id)
event.data["metric_delta"] = metric_delta
event.data["cost_item_id"] = String(cost_item_id)
event.data["cost_amount"] = cost_amount
event.data["target_kind"] = String(SimulationIds.TARGET_ACTIVITY)
event.data["target_action_id"] = String(action_id)
event.data["world_id"] = String(_world_id)
event.data["location_id"] = String(_location_id)
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
if (
normalized == null
or (WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null)
):
return null
_append(normalized)
return normalized
func record_activity_blocked(
tick: int,
actor_id: int,
target_id: StringName,
action_id: StringName,
action_display: String,
world_position: Vector3,
cost_storage_id: StringName,
cost_item_id: StringName,
cost_amount: float
) -> EconomicEventRecord:
var event := EconomicEventRecord.create_narrative(
next_event_id,
SimulationIds.EVENT_TASK_BLOCKED,
tick,
actor_id,
cost_storage_id,
action_display,
world_position,
action_id,
cost_item_id,
cost_amount
)
if target_id.is_empty():
return null
event.data["blocked_contract_kind"] = String(SimulationIds.BLOCKED_CONTRACT_ACTION_COST)
event.data["world_id"] = String(_world_id)
event.data["location_id"] = String(_location_id)
event.data["activity_target_id"] = String(target_id)
event.data["target_kind"] = String(SimulationIds.TARGET_ACTIVITY)
event.data["target_action_id"] = String(action_id)
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
if (
normalized == null
or WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null
):
return null
_append(normalized)
@@ -207,16 +326,27 @@ func get_consumption_rates(
}
func restore(restored_events: Array[EconomicEventRecord], restored_next_id: int) -> void:
func restore(
restored_events: Array[EconomicEventRecord],
restored_next_id: int,
restored_world_id: StringName = &"",
restored_location_id: StringName = &""
) -> bool:
if restored_world_id.is_empty() != restored_location_id.is_empty():
return false
events.clear()
_events_by_id.clear()
_event_ids_by_actor.clear()
_event_ids_by_type.clear()
_event_ids_by_tick.clear()
if not restored_world_id.is_empty():
_world_id = restored_world_id
_location_id = restored_location_id
world_events = WorldEventStore.new()
for event in restored_events:
_index(event)
next_event_id = restored_next_id
return true
func _append(event: EconomicEventRecord) -> void:
@@ -236,7 +366,13 @@ func _index(event: EconomicEventRecord) -> void:
_append_index(_event_ids_by_actor, int(event.data["actor_id"]), event_id)
_append_index(_event_ids_by_type, StringName(event.data["event_type"]), event_id)
_append_index(_event_ids_by_tick, int(event.data["tick"]), event_id)
world_events.append_economic(event, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE)
var event_world_id := StringName(event.data.get("world_id", _world_id))
var event_location_id := StringName(event.data.get("location_id", _location_id))
if event_world_id.is_empty():
event_world_id = _world_id
if event_location_id.is_empty():
event_location_id = _location_id
world_events.append_economic(event, event_world_id, event_location_id)
static func _append_index(index: Dictionary, key: Variant, event_id: int) -> void:
+4 -8
View File
@@ -336,15 +336,11 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool:
)
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
return (
int(event.data["actor_id"]) >= 0
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
and item_id == SimulationIds.RESOURCE_WOOD
and (
StringName(event.data.get("action_id", &""))
in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]
)
and float(event.data.get("required_amount", 0.0)) > 0.0
not ActivityEventValidator.is_legacy_unstructured_blocked(event)
and ActivityEventValidator.is_valid_blocked(event)
)
if ActivityEventValidator.is_candidate(event):
return ActivityEventValidator.is_valid(event)
if (
event_type
in [
+24 -1
View File
@@ -4,6 +4,8 @@ extends RefCounted
const SCHEMA_VERSION := 3
const POSITION_LEGACY_SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
const LEGACY_BLOCKED_WORLD_SCHEMA_MIN := 3
const LEGACY_BLOCKED_WORLD_SCHEMA_MAX := 15
var data: Dictionary
@@ -117,7 +119,17 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
and not normalized["item_id"].is_empty()
and normalized["required_amount"] > 0.0
)
if version == SCHEMA_VERSION and not has_stable_contract:
var has_legacy_marker := (
(
StringName(normalized.get("blocked_contract_kind", &""))
== SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED
)
and (
int(normalized.get("legacy_world_schema_version", -1))
in range(LEGACY_BLOCKED_WORLD_SCHEMA_MIN, LEGACY_BLOCKED_WORLD_SCHEMA_MAX + 1)
)
)
if version == SCHEMA_VERSION and not has_stable_contract and not has_legacy_marker:
return null
return EconomicEventRecord.new(normalized)
@@ -160,5 +172,16 @@ func description(npc_names: Dictionary = {}) -> String:
return "%s was depleted" % source
"animal_fed":
return "%s fed %s %.0f %s" % [actor_name, destination, amount, item]
"activity_completed":
return (
"%s completed %s at %s (+%.1f %s)"
% [
actor_name,
String(data.get("action_id", "activity")).capitalize(),
source,
float(data.get("metric_delta", 0.0)),
String(data.get("metric_id", "benefit")),
]
)
_:
return "tick %d: %s" % [int(data["tick"]), event_type]
+72 -1
View File
@@ -1,8 +1,9 @@
class_name SimulationStateRecord
extends RefCounted
# gdlint: disable=max-file-lines
const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 15
const SCHEMA_VERSION := 16
const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
@@ -16,6 +17,7 @@ const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11
const PLAYER_NEEDS_SCHEMA_VERSION := 12
const CONFLICT_SCHEMA_VERSION := 13
const PRE_EMERGENT_SCHEMA_VERSION := 14
const EMERGENT_SCHEMA_VERSION := 15
const PREVIOUS_SCHEMA_VERSION := 7
var simulation: Dictionary
@@ -137,6 +139,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
PLAYER_NEEDS_SCHEMA_VERSION,
CONFLICT_SCHEMA_VERSION,
PRE_EMERGENT_SCHEMA_VERSION,
EMERGENT_SCHEMA_VERSION,
]
):
record_data = _migrate_legacy(record_data, version)
@@ -187,6 +190,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
"next_journal_entry_id",
"next_commitment_id",
"next_conversation_act_id",
"world_id",
"location_id",
]
)
):
@@ -200,6 +205,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
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"])
var saved_world_id := StringName(simulation_data["world_id"])
var saved_location_id := StringName(simulation_data["location_id"])
if (
not is_finite(saved_tick_interval)
or saved_tick_interval <= 0.0
@@ -212,6 +219,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
or saved_next_journal_entry_id < 0
or saved_next_commitment_id < 0
or saved_next_conversation_act_id < 0
or saved_world_id.is_empty()
or saved_location_id.is_empty()
):
return null
if simulation_data.has("cycle_duration_seconds"):
@@ -447,6 +456,25 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
int(latest_feed_ticks.get(fed_animal_id, AnimalStateRecord.NEVER_FED_TICK)),
int(event_record.data["tick"])
)
elif ActivityEventValidator.is_candidate(event_record):
if not ActivityEventValidator.is_valid_for_state(
event_record,
npc_ids,
int(record.simulation["tick_count"]),
saved_world_id,
saved_location_id
):
return null
elif ActivityEventValidator.is_blocked_candidate(event_record):
if not ActivityEventValidator.is_valid_blocked_for_state(
event_record,
npc_ids,
storage_records_by_id,
int(record.simulation["tick_count"]),
saved_world_id,
saved_location_id
):
return null
record.economic_events.append(event_record)
if int(record.simulation["next_event_id"]) <= highest_event_id:
return null
@@ -932,6 +960,13 @@ 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
if version == EMERGENT_SCHEMA_VERSION:
var scoped_simulation_data: Dictionary = migrated.get("simulation", {})
scoped_simulation_data["world_id"] = String(SimulationIds.REGIONAL_WORLD_BOSNIA)
scoped_simulation_data["location_id"] = String(SimulationIds.REGIONAL_LOCATION_JAJCE)
migrated["simulation"] = scoped_simulation_data
_migrate_blocked_event_scope(migrated, scoped_simulation_data, version)
return migrated
migrated["situations"] = []
migrated["quest_journal"] = []
migrated["commitments"] = []
@@ -941,7 +976,10 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
emergent_simulation_data["next_journal_entry_id"] = 0
emergent_simulation_data["next_commitment_id"] = 0
emergent_simulation_data["next_conversation_act_id"] = 0
emergent_simulation_data["world_id"] = String(SimulationIds.REGIONAL_WORLD_BOSNIA)
emergent_simulation_data["location_id"] = String(SimulationIds.REGIONAL_LOCATION_JAJCE)
migrated["simulation"] = emergent_simulation_data
_migrate_blocked_event_scope(migrated, emergent_simulation_data, version)
if not migrated.has("player"):
migrated["player"] = PlayerStateRecord.create_default().to_dictionary()
if version < CONFLICT_SCHEMA_VERSION:
@@ -1031,6 +1069,39 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
return migrated
static func _migrate_blocked_event_scope(
migrated: Dictionary, simulation_data: Dictionary, source_world_schema_version: int
) -> void:
var migrated_events: Variant = migrated.get("economic_events", [])
if migrated_events is not Array:
return
for event_value in migrated_events:
if (
event_value is Dictionary
and StringName(event_value.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED
):
var event := event_value as Dictionary
var raw_required_amount: Variant = event.get("required_amount", 0.0)
var has_stable_contract := (
not String(event.get("action_id", "")).is_empty()
and not String(event.get("item_id", "")).is_empty()
and (raw_required_amount is int or raw_required_amount is float)
and is_finite(float(raw_required_amount))
and float(raw_required_amount) > 0.0
)
event["blocked_contract_kind"] = String(
(
SimulationIds.BLOCKED_CONTRACT_ACTION_COST
if has_stable_contract
else SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED
)
)
if not has_stable_contract:
event["legacy_world_schema_version"] = source_world_schema_version
event["world_id"] = String(simulation_data["world_id"])
event["location_id"] = String(simulation_data["location_id"])
static func _migrate_npc_familiarity(npc_data: Variant) -> Array[Dictionary]:
var migrated_relationships: Array[Dictionary] = []
if not npc_data is Array: