feat: unify authored activity commands
This commit is contained in:
@@ -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", &""))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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")]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user