1259 lines
50 KiB
GDScript
1259 lines
50 KiB
GDScript
class_name EventService
|
|
extends RefCounted
|
|
|
|
## Deterministic event candidate queue, foreground actor locking and transactional
|
|
## choice authority. Narrative text is deliberately outside this service; only
|
|
## localization keys, content ratings/tags and safe replacement IDs are retained.
|
|
|
|
signal event_registered(definition: EventDefinition)
|
|
signal event_enqueued(instance: EventInstance)
|
|
signal event_started(instance: EventInstance)
|
|
signal event_finished(instance: EventInstance)
|
|
signal event_cancelled(instance: EventInstance, reason: StringName)
|
|
signal choice_resolved(instance_id: StringName, choice_id: StringName, result: Dictionary)
|
|
|
|
const EventDefinitionType = preload("res://scripts/events/event_definition.gd")
|
|
const EventInstanceType = preload("res://scripts/events/event_instance.gd")
|
|
const EventActionType = preload("res://scripts/events/event_action.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
|
|
const SNAPSHOT_VERSION := 1
|
|
const MAX_UNINTERRUPTIBLE_EXTENSION := 60
|
|
|
|
var last_error := ""
|
|
var pause_requested := false
|
|
var last_recovery_repair_report: Dictionary = {}
|
|
|
|
var _definitions: Dictionary = {}
|
|
var _instances: Dictionary = {}
|
|
var _queue: Array[StringName] = []
|
|
var _actor_locks: Dictionary = {}
|
|
var _foreground_instance_id: StringName = &""
|
|
var _active_deduplication_keys: Dictionary = {}
|
|
var _processed_choices: Dictionary = {}
|
|
var _completed_scope_keys: Dictionary = {}
|
|
var _flags: Dictionary = {}
|
|
var _next_event_sequence := 1
|
|
var _next_choice_sequence := 1
|
|
|
|
var _filter_mode: EventDefinition.FilterBehavior = EventDefinition.FilterBehavior.NONE
|
|
var _maximum_content_rating: EventDefinition.ContentRating = EventDefinition.ContentRating.GENERAL
|
|
var _disabled_content_tags: Array[StringName] = []
|
|
|
|
var _actor_exists := Callable()
|
|
var _action_executor := Callable()
|
|
var _cost_reserver := Callable()
|
|
var _cost_releaser := Callable()
|
|
var _cost_committer := Callable()
|
|
var _condition_callbacks: Dictionary = {}
|
|
var _relationship_service: Variant = null
|
|
|
|
|
|
func configure_callbacks(
|
|
actor_exists: Callable = Callable(),
|
|
action_executor: Callable = Callable(),
|
|
cost_reserver: Callable = Callable(),
|
|
cost_releaser: Callable = Callable(),
|
|
cost_committer: Callable = Callable(),
|
|
entity_exists: Callable = Callable(),
|
|
path_exists: Callable = Callable()
|
|
) -> EventService:
|
|
_actor_exists = actor_exists
|
|
_action_executor = action_executor
|
|
_cost_reserver = cost_reserver
|
|
_cost_releaser = cost_releaser
|
|
_cost_committer = cost_committer
|
|
_condition_callbacks = {
|
|
&"entity_exists": entity_exists,
|
|
&"path_exists": path_exists,
|
|
}
|
|
return self
|
|
|
|
|
|
func configure_relationship_service(service: Variant) -> EventService:
|
|
_relationship_service = service
|
|
return self
|
|
|
|
|
|
func configure_content_filter(
|
|
mode: EventDefinition.FilterBehavior,
|
|
maximum_rating: EventDefinition.ContentRating = EventDefinition.ContentRating.GENERAL,
|
|
disabled_tags: Array = []
|
|
) -> EventService:
|
|
_filter_mode = mode
|
|
_maximum_content_rating = maximum_rating
|
|
_disabled_content_tags = _sorted_unique_names(disabled_tags)
|
|
return self
|
|
|
|
|
|
## A small, immutable boundary used by load safety. Callers must not need
|
|
## access to EventService's internal filter fields in order to preserve a
|
|
## stricter locally configured profile across snapshot restoration.
|
|
func get_content_filter_profile() -> Dictionary:
|
|
return {
|
|
&"filter_mode": _filter_mode,
|
|
&"filter_mode_name": EventDefinitionType.FilterBehavior.keys()[_filter_mode],
|
|
&"maximum_content_rating": _maximum_content_rating,
|
|
&"maximum_content_rating_name": EventDefinitionType.ContentRating.keys()[_maximum_content_rating],
|
|
&"disabled_content_tags": _disabled_content_tags.duplicate(),
|
|
}
|
|
|
|
|
|
func register_definition(value: Variant) -> CommandResult:
|
|
if value == null or not value is EventDefinition:
|
|
return _invalid("EventDefinition is missing or invalid.")
|
|
var definition: EventDefinition = value
|
|
if not definition.is_valid():
|
|
return _invalid("EventDefinition fails schema validation.")
|
|
if _definitions.has(definition.event_id):
|
|
return _invalid("Duplicate EventDefinition: %s" % definition.event_id)
|
|
_definitions[definition.event_id] = definition
|
|
event_registered.emit(definition)
|
|
last_error = ""
|
|
return _success({&"event_id": definition.event_id})
|
|
|
|
|
|
func create_definition(event_id: StringName, options: Dictionary = {}) -> CommandResult:
|
|
return register_definition(EventDefinitionType.new().configure(event_id, options))
|
|
|
|
|
|
func get_definition(event_id: StringName) -> EventDefinition:
|
|
return _definitions.get(event_id) as EventDefinition
|
|
|
|
|
|
func get_definitions() -> Array[EventDefinition]:
|
|
var result: Array[EventDefinition] = []
|
|
for event_id: StringName in _sorted_name_keys(_definitions):
|
|
result.append(_definitions[event_id])
|
|
return result
|
|
|
|
|
|
func validate_definitions() -> Dictionary:
|
|
var errors: Array[String] = []
|
|
for definition: EventDefinition in get_definitions():
|
|
if not definition.is_valid():
|
|
errors.append("Invalid definition: %s" % definition.event_id)
|
|
if definition.filter_behavior == EventDefinitionType.FilterBehavior.REPLACE:
|
|
if not _definitions.has(definition.replacement_event_id):
|
|
errors.append("Replacement missing: %s -> %s" % [definition.event_id, definition.replacement_event_id])
|
|
elif _replacement_cycle_exists(definition.event_id):
|
|
errors.append("Replacement cycle: %s" % definition.event_id)
|
|
return {&"ok": errors.is_empty(), &"errors": errors}
|
|
|
|
|
|
func resolve_content_event(event_id: StringName) -> Dictionary:
|
|
var source := get_definition(event_id)
|
|
if source == null:
|
|
return {&"enabled": false, &"reason": &"UNKNOWN_EVENT"}
|
|
if _filter_mode == EventDefinitionType.FilterBehavior.NONE or not _is_content_restricted(source):
|
|
return {
|
|
&"enabled": true,
|
|
&"event_id": event_id,
|
|
&"presentation_mode": &"FULL",
|
|
}
|
|
var behavior := source.filter_behavior
|
|
if behavior == EventDefinitionType.FilterBehavior.NONE:
|
|
behavior = _filter_mode
|
|
match behavior:
|
|
EventDefinitionType.FilterBehavior.FADE:
|
|
return {
|
|
&"enabled": true,
|
|
&"event_id": event_id,
|
|
&"presentation_mode": &"FADE",
|
|
}
|
|
EventDefinitionType.FilterBehavior.REPLACE:
|
|
var replacement := get_definition(source.replacement_event_id)
|
|
if replacement == null or replacement.event_id == source.event_id:
|
|
return {&"enabled": false, &"reason": &"REPLACEMENT_MISSING"}
|
|
if _is_content_restricted(replacement):
|
|
return {&"enabled": false, &"reason": &"REPLACEMENT_UNSAFE"}
|
|
return {
|
|
&"enabled": true,
|
|
&"event_id": replacement.event_id,
|
|
&"presentation_mode": &"REPLACED",
|
|
}
|
|
EventDefinitionType.FilterBehavior.DISABLE:
|
|
return {&"enabled": false, &"reason": &"CONTENT_DISABLED"}
|
|
return {&"enabled": false, &"reason": &"CONTENT_DISABLED"}
|
|
|
|
|
|
func enqueue_candidate(
|
|
event_id: StringName,
|
|
actor_ids: Array,
|
|
absolute_minute: int,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
var source := get_definition(event_id)
|
|
if source == null:
|
|
return _not_found("EventDefinition", event_id)
|
|
if absolute_minute < 0:
|
|
return _invalid("Event candidate requires a non-negative minute.")
|
|
var content_resolution := resolve_content_event(event_id)
|
|
if not bool(content_resolution.get(&"enabled", false)):
|
|
return _invalid("Event candidate was disabled by Content Filter.", content_resolution)
|
|
var resolved_event_id := StringName(content_resolution[&"event_id"])
|
|
var definition := get_definition(resolved_event_id)
|
|
if definition == null:
|
|
return _invalid("Resolved EventDefinition is unavailable.")
|
|
var normalized_actor_ids := _sorted_unique_names(actor_ids)
|
|
var deduplication_key := StringName(_value(
|
|
options,
|
|
&"deduplication_key",
|
|
_default_deduplication_key(event_id, normalized_actor_ids)
|
|
))
|
|
if not deduplication_key.is_empty() and _active_deduplication_keys.has(deduplication_key):
|
|
return _invalid("Duplicate active Event candidate.", {
|
|
&"existing_instance_id": _active_deduplication_keys[deduplication_key],
|
|
})
|
|
var scope_key := _scope_key(source, normalized_actor_ids, absolute_minute)
|
|
if not scope_key.is_empty() and _completed_scope_keys.has(scope_key):
|
|
return _invalid("Event once-scope has already completed.", {&"scope_key": scope_key})
|
|
var instance_id := StringName(_value(options, &"event_instance_id", &""))
|
|
var generated_id := instance_id.is_empty()
|
|
if generated_id:
|
|
instance_id = StringName("event_instance_%08d" % _next_event_sequence)
|
|
if _instances.has(instance_id):
|
|
return _invalid("Duplicate Event instance ID: %s" % instance_id)
|
|
var expires_at := int(_value(options, &"expires_at", -1))
|
|
if expires_at < 0 and definition.expires_after > 0:
|
|
expires_at = absolute_minute + definition.expires_after
|
|
if expires_at >= 0 and expires_at < absolute_minute:
|
|
return _invalid("Event expiry cannot precede creation.")
|
|
var context := _dictionary(_value(options, &"context", {})).duplicate(true)
|
|
context[&"absolute_minute"] = absolute_minute
|
|
context[&"actor_ids"] = normalized_actor_ids.duplicate()
|
|
var instance := EventInstanceType.new().configure(
|
|
instance_id,
|
|
event_id,
|
|
resolved_event_id,
|
|
normalized_actor_ids,
|
|
definition.priority,
|
|
absolute_minute,
|
|
expires_at,
|
|
definition.foreground,
|
|
deduplication_key,
|
|
context,
|
|
StringName(content_resolution.get(&"presentation_mode", &"FULL"))
|
|
)
|
|
_instances[instance_id] = instance
|
|
_queue.append(instance_id)
|
|
if not deduplication_key.is_empty():
|
|
_active_deduplication_keys[deduplication_key] = instance_id
|
|
if generated_id:
|
|
_next_event_sequence += 1
|
|
_sort_queue()
|
|
event_enqueued.emit(instance)
|
|
last_error = ""
|
|
return _success({
|
|
&"event_instance_id": instance_id,
|
|
&"resolved_event_id": resolved_event_id,
|
|
&"presentation_mode": instance.presentation_mode,
|
|
})
|
|
|
|
|
|
func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> CommandResult:
|
|
if absolute_minute < 0:
|
|
return _invalid("Event processing requires a non-negative minute.")
|
|
process_expired(absolute_minute)
|
|
audit_actor_availability(absolute_minute)
|
|
if not _foreground_instance_id.is_empty():
|
|
return _invalid("A foreground Event is already active.", {
|
|
&"event_instance_id": _foreground_instance_id,
|
|
})
|
|
_sort_queue()
|
|
for instance_id: StringName in _queue.duplicate():
|
|
var instance := get_instance(instance_id)
|
|
if instance == null or instance.state != EventInstanceType.State.QUEUED:
|
|
_queue.erase(instance_id)
|
|
continue
|
|
var definition := get_definition(instance.resolved_event_id)
|
|
if definition == null:
|
|
_cancel_instance(instance, &"DEFINITION_MISSING", EventInstanceType.State.ERROR)
|
|
continue
|
|
var missing := _missing_actor_ids(instance.actor_ids)
|
|
if not missing.is_empty():
|
|
_handle_missing_actors(instance, definition, missing, absolute_minute)
|
|
continue
|
|
var context := _merged_context(instance, absolute_minute, extra_context)
|
|
if not definition.conditions_match(context, _condition_callbacks):
|
|
continue
|
|
if instance.foreground and not _acquire_actor_locks(instance):
|
|
continue
|
|
_queue.erase(instance_id)
|
|
instance.state = EventInstanceType.State.RUNNING
|
|
if instance.foreground:
|
|
_foreground_instance_id = instance.event_instance_id
|
|
event_started.emit(instance)
|
|
var automatic_result := _execute_actions(
|
|
definition.automatic_actions,
|
|
definition.transaction_policy,
|
|
definition.failure_actions,
|
|
context
|
|
)
|
|
if not bool(automatic_result.get(&"ok", false)):
|
|
instance.failure_reason = String(automatic_result.get(&"error", "Automatic Event Action failed."))
|
|
if bool(automatic_result.get(&"fatal", false)):
|
|
instance.state = EventInstanceType.State.ERROR
|
|
pause_requested = true
|
|
_finish_instance(instance, false)
|
|
else:
|
|
_cancel_instance(instance, &"ACTION_FAILED", EventInstanceType.State.CANCELLED)
|
|
return _invalid(instance.failure_reason, automatic_result)
|
|
if not definition.choices.is_empty():
|
|
instance.state = EventInstanceType.State.WAITING_CHOICE
|
|
instance.choice_instance_id = StringName("choice_instance_%08d" % _next_choice_sequence)
|
|
_next_choice_sequence += 1
|
|
last_error = ""
|
|
return _success({
|
|
&"event_instance_id": instance.event_instance_id,
|
|
&"choice_instance_id": instance.choice_instance_id,
|
|
&"waiting_for_choice": true,
|
|
})
|
|
_finish_instance(instance, true)
|
|
last_error = ""
|
|
return _success({
|
|
&"event_instance_id": instance.event_instance_id,
|
|
&"completed": true,
|
|
})
|
|
return _invalid("No eligible Event candidate is ready.")
|
|
|
|
|
|
func process_expired(absolute_minute: int) -> Array[Dictionary]:
|
|
var results: Array[Dictionary] = []
|
|
for instance_id: StringName in _queue.duplicate():
|
|
var instance := get_instance(instance_id)
|
|
if instance == null or instance.expires_at < 0 or absolute_minute <= instance.expires_at:
|
|
continue
|
|
var definition := get_definition(instance.resolved_event_id)
|
|
var context := _merged_context(instance, absolute_minute, {})
|
|
var fallback_result := {&"ok": true}
|
|
if definition != null and not definition.fallback_actions.is_empty():
|
|
fallback_result = _execute_actions(
|
|
definition.fallback_actions,
|
|
EventDefinitionType.TransactionPolicy.ROLLBACK_ALL,
|
|
[],
|
|
context
|
|
)
|
|
_cancel_instance(instance, &"EXPIRED", EventInstanceType.State.EXPIRED)
|
|
results.append({
|
|
&"event_instance_id": instance_id,
|
|
&"fallback_ok": bool(fallback_result.get(&"ok", false)),
|
|
})
|
|
return results
|
|
|
|
|
|
func reserve_choice_cost(
|
|
event_instance_id: StringName,
|
|
choice_id: StringName,
|
|
absolute_minute: int,
|
|
extra_context: Dictionary = {}
|
|
) -> CommandResult:
|
|
var instance := get_instance(event_instance_id)
|
|
if instance == null:
|
|
return _not_found("EventInstance", event_instance_id)
|
|
if instance.state != EventInstanceType.State.WAITING_CHOICE:
|
|
return _invalid("Event is not waiting for a Choice.")
|
|
var definition := get_definition(instance.resolved_event_id)
|
|
var choice := definition.get_choice(choice_id) if definition != null else null
|
|
if choice == null:
|
|
return _not_found("EventChoice", choice_id)
|
|
var missing := _missing_actor_ids(instance.actor_ids)
|
|
if not missing.is_empty():
|
|
_handle_missing_actors(instance, definition, missing, absolute_minute)
|
|
return _invalid("Event actor is unavailable.", {&"missing_actor_ids": missing})
|
|
var context := _merged_context(instance, absolute_minute, extra_context)
|
|
if not choice.conditions_match(context, _condition_callbacks):
|
|
return _invalid("Choice conditions changed and no longer pass.")
|
|
if instance.reserved_costs.has(choice_id):
|
|
var existing: Dictionary = instance.reserved_costs[choice_id]
|
|
return _success({&"reservation": existing.duplicate(true), &"is_replay": true})
|
|
var reserve_result := _reserve_cost(choice.cost, context)
|
|
if not bool(reserve_result.get(&"ok", false)):
|
|
return _invalid(String(reserve_result.get(&"error", "Choice cost is unavailable.")), reserve_result)
|
|
instance.reserved_costs[choice_id] = {
|
|
&"cost": choice.cost.duplicate(true),
|
|
&"token": _copy(reserve_result.get(&"token", choice.cost)),
|
|
}
|
|
last_error = ""
|
|
return _success({&"reservation": instance.reserved_costs[choice_id].duplicate(true)})
|
|
|
|
|
|
func submit_choice(
|
|
event_instance_id: StringName,
|
|
choice_instance_id: StringName,
|
|
choice_id: StringName,
|
|
absolute_minute: int,
|
|
extra_context: Dictionary = {}
|
|
) -> CommandResult:
|
|
if _processed_choices.has(choice_instance_id):
|
|
var replay := CommandResultType.from_dictionary(_processed_choices[choice_instance_id])
|
|
replay.is_replay = true
|
|
return replay
|
|
var instance := get_instance(event_instance_id)
|
|
if instance == null:
|
|
return _not_found("EventInstance", event_instance_id)
|
|
if choice_instance_id.is_empty() or instance.choice_instance_id != choice_instance_id:
|
|
return _invalid("Choice instance token does not match the active Event.")
|
|
if instance.state != EventInstanceType.State.WAITING_CHOICE:
|
|
return _invalid("Event is not waiting for a Choice.")
|
|
var definition := get_definition(instance.resolved_event_id)
|
|
if definition == null:
|
|
return _invalid("EventDefinition is missing.")
|
|
var missing := _missing_actor_ids(instance.actor_ids)
|
|
if not missing.is_empty():
|
|
_handle_missing_actors(instance, definition, missing, absolute_minute)
|
|
return _invalid("Event actor is unavailable.", {&"missing_actor_ids": missing})
|
|
var choice := definition.get_choice(choice_id)
|
|
if choice == null:
|
|
return _not_found("EventChoice", choice_id)
|
|
var context := _merged_context(instance, absolute_minute, extra_context)
|
|
if not choice.conditions_match(context, _condition_callbacks):
|
|
return _invalid("Choice conditions changed and no longer pass.")
|
|
if not instance.reserved_costs.has(choice_id):
|
|
var reserve_result := reserve_choice_cost(event_instance_id, choice_id, absolute_minute, extra_context)
|
|
if not reserve_result.is_success():
|
|
return reserve_result
|
|
var transaction_result := _execute_actions(
|
|
choice.actions,
|
|
definition.transaction_policy,
|
|
definition.failure_actions,
|
|
context
|
|
)
|
|
if not bool(transaction_result.get(&"ok", false)):
|
|
_release_reserved_cost(instance, choice_id, context)
|
|
if bool(transaction_result.get(&"fatal", false)):
|
|
instance.state = EventInstanceType.State.ERROR
|
|
instance.failure_reason = String(transaction_result.get(&"error", "Failure branch failed."))
|
|
pause_requested = true
|
|
_finish_instance(instance, false)
|
|
var failed_result := _invalid(
|
|
String(transaction_result.get(&"error", "Choice Action transaction failed.")),
|
|
transaction_result
|
|
)
|
|
_cache_choice_result(choice_instance_id, failed_result)
|
|
return failed_result
|
|
var commit_result := _commit_reserved_cost(instance, choice_id, context)
|
|
if not bool(commit_result.get(&"ok", false)):
|
|
_rollback_records(transaction_result.get(&"records", []), context)
|
|
_release_reserved_cost(instance, choice_id, context)
|
|
var commit_failure := _invalid(
|
|
String(commit_result.get(&"error", "Choice cost commit failed.")),
|
|
commit_result
|
|
)
|
|
_cache_choice_result(choice_instance_id, commit_failure)
|
|
return commit_failure
|
|
instance.selected_choice_id = choice_id
|
|
_finish_instance(instance, true)
|
|
var result := _success({
|
|
&"event_instance_id": event_instance_id,
|
|
&"choice_instance_id": choice_instance_id,
|
|
&"choice_id": choice_id,
|
|
&"completed": true,
|
|
&"skipped_action_ids": transaction_result.get(&"skipped_action_ids", []),
|
|
&"branched_failure": bool(transaction_result.get(&"branched_failure", false)),
|
|
})
|
|
_cache_choice_result(choice_instance_id, result)
|
|
choice_resolved.emit(event_instance_id, choice_id, result.to_dictionary())
|
|
last_error = ""
|
|
return result
|
|
|
|
|
|
func audit_actor_availability(absolute_minute: int) -> Array[Dictionary]:
|
|
var reports: Array[Dictionary] = []
|
|
for instance: EventInstance in get_instances():
|
|
if instance.state not in [EventInstanceType.State.RUNNING, EventInstanceType.State.WAITING_CHOICE, EventInstanceType.State.INTERRUPTED]:
|
|
continue
|
|
var missing := _missing_actor_ids(instance.actor_ids)
|
|
if missing.is_empty():
|
|
continue
|
|
var definition := get_definition(instance.resolved_event_id)
|
|
_handle_missing_actors(instance, definition, missing, absolute_minute)
|
|
reports.append({
|
|
&"event_instance_id": instance.event_instance_id,
|
|
&"missing_actor_ids": missing,
|
|
&"state": EventInstanceType.State.keys()[instance.state],
|
|
})
|
|
return reports
|
|
|
|
|
|
func cancel_event(event_instance_id: StringName, reason: StringName = &"CANCELLED") -> CommandResult:
|
|
var instance := get_instance(event_instance_id)
|
|
if instance == null:
|
|
return _not_found("EventInstance", event_instance_id)
|
|
if instance.is_terminal():
|
|
return _invalid("Event is already terminal.")
|
|
_cancel_instance(instance, reason, EventInstanceType.State.CANCELLED)
|
|
last_error = ""
|
|
return _success({&"event_instance_id": event_instance_id})
|
|
|
|
|
|
func get_instance(event_instance_id: StringName) -> EventInstance:
|
|
return _instances.get(event_instance_id) as EventInstance
|
|
|
|
|
|
func get_instances() -> Array[EventInstance]:
|
|
var result: Array[EventInstance] = []
|
|
for instance_id: StringName in _sorted_name_keys(_instances):
|
|
result.append(_instances[instance_id])
|
|
return result
|
|
|
|
|
|
func get_queued_instances() -> Array[EventInstance]:
|
|
_sort_queue()
|
|
var result: Array[EventInstance] = []
|
|
for instance_id: StringName in _queue:
|
|
var instance := get_instance(instance_id)
|
|
if instance != null:
|
|
result.append(instance)
|
|
return result
|
|
|
|
|
|
func get_foreground_instance() -> EventInstance:
|
|
return get_instance(_foreground_instance_id)
|
|
|
|
|
|
func get_actor_locks() -> Dictionary:
|
|
return _actor_locks.duplicate(true)
|
|
|
|
|
|
func has_flag(flag_id: StringName) -> bool:
|
|
return bool(_flags.get(flag_id, false))
|
|
|
|
|
|
func validate_invariants() -> Dictionary:
|
|
var errors: Array[String] = []
|
|
var queued_seen: Dictionary = {}
|
|
for instance_id: StringName in _queue:
|
|
if queued_seen.has(instance_id):
|
|
errors.append("Duplicate queue instance: %s" % instance_id)
|
|
queued_seen[instance_id] = true
|
|
var instance := get_instance(instance_id)
|
|
if instance == null or instance.state != EventInstanceType.State.QUEUED:
|
|
errors.append("Queue references a non-QUEUED instance: %s" % instance_id)
|
|
for instance: EventInstance in get_instances():
|
|
if not instance.is_valid() or not _definitions.has(instance.resolved_event_id):
|
|
errors.append("Invalid EventInstance: %s" % instance.event_instance_id)
|
|
if not _foreground_instance_id.is_empty():
|
|
var foreground := get_instance(_foreground_instance_id)
|
|
if foreground == null or not foreground.foreground or foreground.state not in [EventInstanceType.State.RUNNING, EventInstanceType.State.WAITING_CHOICE, EventInstanceType.State.INTERRUPTED]:
|
|
errors.append("Foreground pointer is invalid.")
|
|
for raw_actor_id: Variant in _actor_locks:
|
|
var actor_id := StringName(raw_actor_id)
|
|
var instance_id := StringName(_actor_locks[raw_actor_id])
|
|
var instance := get_instance(instance_id)
|
|
if instance == null or not instance.actor_ids.has(actor_id) or instance_id != _foreground_instance_id:
|
|
errors.append("Actor Lock is orphaned: %s" % actor_id)
|
|
return {&"ok": errors.is_empty(), &"errors": errors}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var instances: Array[Dictionary] = []
|
|
for instance: EventInstance in get_instances():
|
|
instances.append(instance.create_snapshot())
|
|
var queue_strings: Array[String] = []
|
|
for instance_id: StringName in _queue:
|
|
queue_strings.append(String(instance_id))
|
|
return {
|
|
&"version": SNAPSHOT_VERSION,
|
|
&"instances": instances,
|
|
&"queue": queue_strings,
|
|
&"actor_locks": _string_key_dictionary(_actor_locks),
|
|
&"foreground_instance_id": String(_foreground_instance_id),
|
|
&"active_deduplication_keys": _string_key_dictionary(_active_deduplication_keys),
|
|
&"processed_choices": _string_key_dictionary(_processed_choices),
|
|
&"completed_scope_keys": _string_key_dictionary(_completed_scope_keys),
|
|
&"flags": _string_key_dictionary(_flags),
|
|
&"next_event_sequence": _next_event_sequence,
|
|
&"next_choice_sequence": _next_choice_sequence,
|
|
&"pause_requested": pause_requested,
|
|
&"filter_mode": EventDefinitionType.FilterBehavior.keys()[_filter_mode],
|
|
&"maximum_content_rating": EventDefinitionType.ContentRating.keys()[_maximum_content_rating],
|
|
&"disabled_content_tags": _names_to_strings(_disabled_content_tags),
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
var parsed := _parse_snapshot(snapshot)
|
|
if not bool(parsed.get(&"ok", false)):
|
|
last_error = String(parsed.get(&"error", "Invalid EventService snapshot."))
|
|
return false
|
|
_instances = parsed[&"instances"]
|
|
_queue = parsed[&"queue"]
|
|
_actor_locks = parsed[&"actor_locks"]
|
|
_foreground_instance_id = parsed[&"foreground_instance_id"]
|
|
_active_deduplication_keys = parsed[&"active_deduplication_keys"]
|
|
_processed_choices = parsed[&"processed_choices"]
|
|
_completed_scope_keys = parsed[&"completed_scope_keys"]
|
|
_flags = parsed[&"flags"]
|
|
_next_event_sequence = parsed[&"next_event_sequence"]
|
|
_next_choice_sequence = parsed[&"next_choice_sequence"]
|
|
pause_requested = parsed[&"pause_requested"]
|
|
_filter_mode = parsed[&"filter_mode"]
|
|
_maximum_content_rating = parsed[&"maximum_content_rating"]
|
|
_disabled_content_tags = parsed[&"disabled_content_tags"]
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
## Recovery-only boundary. It can release stale Actor Locks and the matching
|
|
## foreground pointer only when that foreground instance is absent or already
|
|
## terminal. Active-foreground lock mismatches remain hard validation errors;
|
|
## this routine never invents actors, events, choices, or route ownership.
|
|
func restore_snapshot_with_lock_recovery(snapshot: Dictionary) -> bool:
|
|
last_recovery_repair_report = {
|
|
&"automatic_repairs_applied": false,
|
|
&"released_actor_ids": [],
|
|
&"released_foreground_instance_id": &"",
|
|
&"reason": &"NONE",
|
|
}
|
|
var raw_instances: Variant = _value(snapshot, &"instances", null)
|
|
var raw_locks: Variant = _value(snapshot, &"actor_locks", null)
|
|
if not raw_instances is Array or not raw_locks is Dictionary:
|
|
return restore_snapshot(snapshot)
|
|
var foreground_id := StringName(_value(snapshot, &"foreground_instance_id", &""))
|
|
if foreground_id.is_empty():
|
|
return restore_snapshot(snapshot)
|
|
|
|
var matching_foreground: Dictionary = {}
|
|
for raw_instance: Variant in raw_instances:
|
|
if raw_instance is Dictionary and StringName(_value(raw_instance, &"event_instance_id", &"")) == foreground_id:
|
|
matching_foreground = raw_instance
|
|
break
|
|
var repair_reason: StringName = &"MISSING_FOREGROUND"
|
|
var known_foreground_actors: Array[StringName] = []
|
|
if not matching_foreground.is_empty():
|
|
var state_name := String(_value(matching_foreground, &"state", "")).to_upper()
|
|
var state_index := EventInstanceType.State.keys().find(state_name)
|
|
if state_index < 0:
|
|
return restore_snapshot(snapshot)
|
|
var state := state_index as EventInstance.State
|
|
if not bool(_value(matching_foreground, &"foreground", true)) or state not in [
|
|
EventInstanceType.State.COMPLETED,
|
|
EventInstanceType.State.CANCELLED,
|
|
EventInstanceType.State.EXPIRED,
|
|
EventInstanceType.State.ERROR,
|
|
]:
|
|
return restore_snapshot(snapshot)
|
|
repair_reason = &"TERMINAL_FOREGROUND"
|
|
var raw_actor_ids: Variant = _value(matching_foreground, &"actor_ids", null)
|
|
if not raw_actor_ids is Array:
|
|
return restore_snapshot(snapshot)
|
|
known_foreground_actors = _sorted_unique_names(raw_actor_ids)
|
|
|
|
var candidate := snapshot.duplicate(true)
|
|
var released_actor_ids: Array[StringName] = []
|
|
var retained_locks: Dictionary = {}
|
|
for raw_actor_id: Variant in raw_locks:
|
|
var actor_id := StringName(raw_actor_id)
|
|
var lock_instance_id := StringName(raw_locks[raw_actor_id])
|
|
var belongs_to_known_actor_set := (
|
|
matching_foreground.is_empty()
|
|
or known_foreground_actors.has(actor_id)
|
|
)
|
|
if actor_id.is_empty() or lock_instance_id != foreground_id or not belongs_to_known_actor_set:
|
|
last_error = "EventService recovery found unrelated Actor Lock corruption."
|
|
return false
|
|
released_actor_ids.append(actor_id)
|
|
released_actor_ids.sort_custom(
|
|
func(left: StringName, right: StringName) -> bool:
|
|
return String(left) < String(right)
|
|
)
|
|
candidate[&"foreground_instance_id"] = ""
|
|
candidate[&"actor_locks"] = retained_locks
|
|
if not restore_snapshot(candidate):
|
|
return false
|
|
last_recovery_repair_report = {
|
|
&"automatic_repairs_applied": true,
|
|
&"released_actor_ids": released_actor_ids,
|
|
&"released_foreground_instance_id": foreground_id,
|
|
&"reason": repair_reason,
|
|
}
|
|
return true
|
|
|
|
|
|
func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION:
|
|
return {&"ok": false, &"error": "Unsupported EventService snapshot version."}
|
|
var raw_instances: Variant = _value(snapshot, &"instances", null)
|
|
var raw_queue: Variant = _value(snapshot, &"queue", null)
|
|
var raw_locks: Variant = _value(snapshot, &"actor_locks", null)
|
|
if not raw_instances is Array or not raw_queue is Array or not raw_locks is Dictionary:
|
|
return {&"ok": false, &"error": "EventService snapshot collections are malformed."}
|
|
var instances: Dictionary = {}
|
|
for raw_instance: Variant in raw_instances:
|
|
if not raw_instance is Dictionary:
|
|
return {&"ok": false, &"error": "EventInstance snapshot is malformed."}
|
|
var instance := EventInstanceType.from_snapshot(raw_instance)
|
|
if not instance.is_valid() or instances.has(instance.event_instance_id) or not _definitions.has(instance.resolved_event_id):
|
|
return {&"ok": false, &"error": "EventInstance snapshot reference is invalid."}
|
|
instances[instance.event_instance_id] = instance
|
|
var queue: Array[StringName] = []
|
|
var queue_seen: Dictionary = {}
|
|
for raw_instance_id: Variant in raw_queue:
|
|
var instance_id := StringName(raw_instance_id)
|
|
var instance: EventInstance = instances.get(instance_id)
|
|
if instance == null or instance.state != EventInstanceType.State.QUEUED or queue_seen.has(instance_id):
|
|
return {&"ok": false, &"error": "Event queue reference is invalid."}
|
|
queue_seen[instance_id] = true
|
|
queue.append(instance_id)
|
|
var foreground_id := StringName(_value(snapshot, &"foreground_instance_id", &""))
|
|
if not foreground_id.is_empty():
|
|
var foreground: EventInstance = instances.get(foreground_id)
|
|
if foreground == null or not foreground.foreground or foreground.state not in [EventInstanceType.State.RUNNING, EventInstanceType.State.WAITING_CHOICE, EventInstanceType.State.INTERRUPTED]:
|
|
return {&"ok": false, &"error": "Foreground Event reference is invalid."}
|
|
var locks: Dictionary = {}
|
|
for raw_actor_id: Variant in raw_locks:
|
|
var actor_id := StringName(raw_actor_id)
|
|
var instance_id := StringName(raw_locks[raw_actor_id])
|
|
var locked_instance: EventInstance = instances.get(instance_id)
|
|
if actor_id.is_empty() or locked_instance == null or instance_id != foreground_id or not locked_instance.actor_ids.has(actor_id):
|
|
return {&"ok": false, &"error": "Actor Lock snapshot is invalid."}
|
|
locks[actor_id] = instance_id
|
|
if not foreground_id.is_empty():
|
|
var active: EventInstance = instances[foreground_id]
|
|
for actor_id: StringName in active.actor_ids:
|
|
if locks.get(actor_id, &"") != foreground_id:
|
|
return {&"ok": false, &"error": "Foreground Actor Lock is incomplete."}
|
|
var active_dedup := _name_dictionary(_value(snapshot, &"active_deduplication_keys", {}))
|
|
for raw_key: Variant in active_dedup:
|
|
var instance_id := StringName(active_dedup[raw_key])
|
|
var instance: EventInstance = instances.get(instance_id)
|
|
if instance == null or instance.is_terminal() or instance.deduplication_key != StringName(raw_key):
|
|
return {&"ok": false, &"error": "Active deduplication index is invalid."}
|
|
var next_event := int(_value(snapshot, &"next_event_sequence", 0))
|
|
var next_choice := int(_value(snapshot, &"next_choice_sequence", 0))
|
|
if next_event < 1 or next_choice < 1:
|
|
return {&"ok": false, &"error": "Event sequence counters are invalid."}
|
|
var filter_mode_value := _strict_enum_value(
|
|
EventDefinitionType.FilterBehavior.keys(),
|
|
_value(snapshot, &"filter_mode", null)
|
|
)
|
|
if filter_mode_value < 0:
|
|
return {&"ok": false, &"error": "EventService filter_mode snapshot enum is invalid."}
|
|
var maximum_rating_value := _strict_enum_value(
|
|
EventDefinitionType.ContentRating.keys(),
|
|
_value(snapshot, &"maximum_content_rating", null)
|
|
)
|
|
if maximum_rating_value < 0:
|
|
return {&"ok": false, &"error": "EventService maximum_content_rating snapshot enum is invalid."}
|
|
var raw_disabled_tags: Variant = _value(snapshot, &"disabled_content_tags", null)
|
|
if not raw_disabled_tags is Array:
|
|
return {&"ok": false, &"error": "EventService disabled_content_tags snapshot is malformed."}
|
|
var filter_mode := filter_mode_value as EventDefinition.FilterBehavior
|
|
var maximum_rating := maximum_rating_value as EventDefinition.ContentRating
|
|
return {
|
|
&"ok": true,
|
|
&"instances": instances,
|
|
&"queue": queue,
|
|
&"actor_locks": locks,
|
|
&"foreground_instance_id": foreground_id,
|
|
&"active_deduplication_keys": active_dedup,
|
|
&"processed_choices": _dictionary(_value(snapshot, &"processed_choices", {})).duplicate(true),
|
|
&"completed_scope_keys": _dictionary(_value(snapshot, &"completed_scope_keys", {})).duplicate(true),
|
|
&"flags": _dictionary(_value(snapshot, &"flags", {})).duplicate(true),
|
|
&"next_event_sequence": next_event,
|
|
&"next_choice_sequence": next_choice,
|
|
&"pause_requested": bool(_value(snapshot, &"pause_requested", false)),
|
|
&"filter_mode": filter_mode,
|
|
&"maximum_content_rating": maximum_rating,
|
|
&"disabled_content_tags": _sorted_unique_names(raw_disabled_tags),
|
|
}
|
|
|
|
|
|
func _execute_actions(
|
|
actions: Array,
|
|
policy: EventDefinition.TransactionPolicy,
|
|
failure_actions: Array,
|
|
context: Dictionary
|
|
) -> Dictionary:
|
|
match policy:
|
|
EventDefinitionType.TransactionPolicy.SKIP_FAILED:
|
|
var records: Array[Dictionary] = []
|
|
var skipped: Array[StringName] = []
|
|
for action: EventAction in actions:
|
|
var validation := _execute_action(action, &"validate", context, null)
|
|
if not bool(validation.get(&"ok", false)):
|
|
skipped.append(action.action_id)
|
|
continue
|
|
var applied := _execute_action(action, &"apply", context, null)
|
|
if bool(applied.get(&"ok", false)):
|
|
records.append({&"action": action, &"token": _copy(applied.get(&"token", null))})
|
|
else:
|
|
skipped.append(action.action_id)
|
|
return {&"ok": true, &"records": records, &"skipped_action_ids": skipped}
|
|
EventDefinitionType.TransactionPolicy.BRANCH_FAILURE:
|
|
var primary := _run_rollback_all(actions, context)
|
|
if bool(primary.get(&"ok", false)):
|
|
return primary
|
|
var branch := _run_rollback_all(failure_actions, context)
|
|
if bool(branch.get(&"ok", false)):
|
|
branch[&"branched_failure"] = true
|
|
branch[&"primary_error"] = primary.get(&"error", "")
|
|
return branch
|
|
return {
|
|
&"ok": false,
|
|
&"fatal": true,
|
|
&"error": "Failure branch failed: %s" % branch.get(&"error", "unknown error"),
|
|
}
|
|
return _run_rollback_all(actions, context)
|
|
|
|
|
|
func _run_rollback_all(actions: Array, context: Dictionary) -> Dictionary:
|
|
for action: EventAction in actions:
|
|
var validation := _execute_action(action, &"validate", context, null)
|
|
if not bool(validation.get(&"ok", false)):
|
|
return {&"ok": false, &"error": validation.get(&"error", "Action validation failed."), &"records": []}
|
|
var records: Array[Dictionary] = []
|
|
for action: EventAction in actions:
|
|
var applied := _execute_action(action, &"apply", context, null)
|
|
if not bool(applied.get(&"ok", false)):
|
|
_rollback_records(records, context)
|
|
return {&"ok": false, &"error": applied.get(&"error", "Action apply failed."), &"records": []}
|
|
records.append({&"action": action, &"token": _copy(applied.get(&"token", null))})
|
|
return {&"ok": true, &"records": records, &"skipped_action_ids": []}
|
|
|
|
|
|
func _rollback_records(raw_records: Variant, context: Dictionary) -> void:
|
|
if not raw_records is Array:
|
|
return
|
|
for index: int in range(raw_records.size() - 1, -1, -1):
|
|
var record: Dictionary = raw_records[index]
|
|
_execute_action(record[&"action"], &"rollback", context, record.get(&"token"))
|
|
|
|
|
|
func _execute_action(
|
|
action: EventAction,
|
|
phase: StringName,
|
|
context: Dictionary,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
if _action_executor.is_valid():
|
|
var custom_result: Variant = _action_executor.call(action, phase, context, rollback_token)
|
|
var normalized := _normalize_operation_result(custom_result)
|
|
if not bool(normalized.get(&"not_handled", false)):
|
|
return normalized
|
|
return _execute_builtin_action(action, phase, context, rollback_token)
|
|
|
|
|
|
func _execute_builtin_action(
|
|
action: EventAction,
|
|
phase: StringName,
|
|
context: Dictionary,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
match action.type:
|
|
EventActionType.Type.SET_FLAG:
|
|
var flag_id := StringName(_value(action.payload, &"flag_id", &""))
|
|
if flag_id.is_empty():
|
|
return {&"ok": false, &"error": "SET_FLAG requires flag_id."}
|
|
if phase == &"validate":
|
|
return {&"ok": true}
|
|
if phase == &"apply":
|
|
var token := {&"had": _flags.has(flag_id), &"value": _flags.get(flag_id)}
|
|
_flags[flag_id] = bool(_value(action.payload, &"enabled", true))
|
|
return {&"ok": true, &"token": token}
|
|
if phase == &"rollback" and rollback_token is Dictionary:
|
|
if bool(rollback_token.get(&"had", false)):
|
|
_flags[flag_id] = rollback_token.get(&"value")
|
|
else:
|
|
_flags.erase(flag_id)
|
|
return {&"ok": true}
|
|
EventActionType.Type.SCHEDULE_EVENT:
|
|
var event_id := StringName(_value(action.payload, &"event_id", &""))
|
|
if event_id.is_empty() or not _definitions.has(event_id):
|
|
return {&"ok": false, &"error": "SCHEDULE_EVENT references an unknown event."}
|
|
if phase == &"validate":
|
|
return {&"ok": true}
|
|
if phase == &"apply":
|
|
var result := enqueue_candidate(
|
|
event_id,
|
|
_array(_value(action.payload, &"actor_ids", _value(context, &"actor_ids", []))),
|
|
int(_value(context, &"absolute_minute", 0)),
|
|
_dictionary(_value(action.payload, &"options", {}))
|
|
)
|
|
return {&"ok": result.is_success(), &"error": result.message, &"token": result.details.get(&"event_instance_id", &"")}
|
|
if phase == &"rollback":
|
|
var instance_id := StringName(rollback_token)
|
|
var scheduled := get_instance(instance_id)
|
|
if scheduled != null and not scheduled.is_terminal():
|
|
_cancel_instance(scheduled, &"TRANSACTION_ROLLBACK", EventInstanceType.State.CANCELLED)
|
|
return {&"ok": true}
|
|
EventActionType.Type.ADD_RELATIONSHIP_VALUE, EventActionType.Type.DAMAGE_SHIELD, EventActionType.Type.SET_ROUTE_LOCK:
|
|
return _execute_relationship_action(action, phase, context, rollback_token)
|
|
return {&"ok": false, &"error": "No executor is configured for Action type %s." % EventActionType.Type.keys()[action.type]}
|
|
|
|
|
|
func _execute_relationship_action(
|
|
action: EventAction,
|
|
phase: StringName,
|
|
context: Dictionary,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
if _relationship_service == null:
|
|
return {&"ok": false, &"error": "RelationshipService is not configured."}
|
|
var relationship_id := StringName(_value(action.payload, &"relationship_id", &""))
|
|
if relationship_id.is_empty() or _relationship_service.get_relationship(relationship_id) == null:
|
|
return {&"ok": false, &"error": "Relationship Action references an unknown state."}
|
|
if phase == &"validate":
|
|
return {&"ok": true}
|
|
if phase == &"rollback":
|
|
if rollback_token is Dictionary and rollback_token.has(&"snapshot"):
|
|
return {&"ok": bool(_relationship_service.restore_snapshot(rollback_token[&"snapshot"]))}
|
|
return {&"ok": false, &"error": "Relationship rollback token is missing."}
|
|
var token := {&"snapshot": _relationship_service.create_snapshot()}
|
|
var result: Variant
|
|
match action.type:
|
|
EventActionType.Type.ADD_RELATIONSHIP_VALUE:
|
|
result = _relationship_service.apply_value_change(
|
|
relationship_id,
|
|
StringName(_value(action.payload, &"metric_id", &"")),
|
|
int(_value(action.payload, &"delta", 0)),
|
|
int(_value(context, &"absolute_minute", 0)),
|
|
_dictionary(_value(action.payload, &"options", {&"important": true, &"allow_threshold_crossing": true}))
|
|
)
|
|
EventActionType.Type.DAMAGE_SHIELD:
|
|
result = _relationship_service.apply_shield_attack(
|
|
relationship_id,
|
|
StringName(_value(action.payload, &"action_key", action.action_id)),
|
|
int(_value(context, &"absolute_minute", 0)),
|
|
_dictionary(_value(action.payload, &"terms", action.payload))
|
|
)
|
|
EventActionType.Type.SET_ROUTE_LOCK:
|
|
var lock_value := int(_value(action.payload, &"route_lock", 0))
|
|
result = _relationship_service.request_route_lock(
|
|
relationship_id,
|
|
lock_value,
|
|
StringName(_value(action.payload, &"route_id", &"")),
|
|
bool(_value(action.payload, &"irreversible_event", false))
|
|
)
|
|
return {&"ok": result.is_success(), &"error": result.message, &"token": token}
|
|
|
|
|
|
func _handle_missing_actors(
|
|
instance: EventInstance,
|
|
definition: EventDefinition,
|
|
missing_actor_ids: Array[StringName],
|
|
absolute_minute: int
|
|
) -> void:
|
|
_release_all_reserved_costs(instance, _merged_context(instance, absolute_minute, {}))
|
|
if definition == null:
|
|
_cancel_instance(instance, &"DEFINITION_MISSING", EventInstanceType.State.ERROR)
|
|
return
|
|
match definition.missing_actor_policy:
|
|
EventDefinitionType.MissingActorPolicy.CONTINUE_WITHOUT:
|
|
for actor_id: StringName in missing_actor_ids:
|
|
instance.actor_ids.erase(actor_id)
|
|
_actor_locks.erase(actor_id)
|
|
if instance.actor_ids.is_empty() and instance.foreground:
|
|
_cancel_instance(instance, &"ALL_ACTORS_MISSING", EventInstanceType.State.CANCELLED)
|
|
EventDefinitionType.MissingActorPolicy.REPLACE_WITH_ROLE:
|
|
var replacements: Dictionary = _dictionary(_value(instance.context, &"replacement_actor_ids", {}))
|
|
var replacement_failed := false
|
|
for actor_id: StringName in missing_actor_ids:
|
|
var replacement_id := StringName(_value(replacements, actor_id, &""))
|
|
if replacement_id.is_empty() or not _actor_is_available(replacement_id):
|
|
replacement_failed = true
|
|
break
|
|
instance.actor_ids.erase(actor_id)
|
|
instance.actor_ids.append(replacement_id)
|
|
_actor_locks.erase(actor_id)
|
|
_actor_locks[replacement_id] = instance.event_instance_id
|
|
instance.actor_ids = _sorted_unique_names(instance.actor_ids)
|
|
if replacement_failed:
|
|
_cancel_instance(instance, &"ACTOR_REPLACEMENT_FAILED", EventInstanceType.State.CANCELLED)
|
|
EventDefinitionType.MissingActorPolicy.BRANCH_FAILURE:
|
|
var branch := _execute_actions(
|
|
definition.failure_actions,
|
|
EventDefinitionType.TransactionPolicy.ROLLBACK_ALL,
|
|
[],
|
|
_merged_context(instance, absolute_minute, {&"missing_actor_ids": missing_actor_ids})
|
|
)
|
|
if bool(branch.get(&"ok", false)):
|
|
_cancel_instance(instance, &"ACTOR_MISSING_BRANCH", EventInstanceType.State.CANCELLED)
|
|
else:
|
|
instance.failure_reason = String(branch.get(&"error", "Missing Actor failure branch failed."))
|
|
_cancel_instance(instance, &"ACTOR_MISSING_BRANCH_FAILED", EventInstanceType.State.ERROR)
|
|
pause_requested = true
|
|
_:
|
|
_cancel_instance(instance, &"ACTOR_MISSING", EventInstanceType.State.CANCELLED)
|
|
|
|
|
|
func _acquire_actor_locks(instance: EventInstance) -> bool:
|
|
for actor_id: StringName in instance.actor_ids:
|
|
if _actor_locks.has(actor_id):
|
|
return false
|
|
for actor_id: StringName in instance.actor_ids:
|
|
_actor_locks[actor_id] = instance.event_instance_id
|
|
return true
|
|
|
|
|
|
func _release_actor_locks(instance: EventInstance) -> void:
|
|
for actor_id: StringName in instance.actor_ids:
|
|
if _actor_locks.get(actor_id, &"") == instance.event_instance_id:
|
|
_actor_locks.erase(actor_id)
|
|
if _foreground_instance_id == instance.event_instance_id:
|
|
_foreground_instance_id = &""
|
|
|
|
|
|
func _finish_instance(instance: EventInstance, completed: bool) -> void:
|
|
_release_all_reserved_costs(instance, instance.context)
|
|
_release_actor_locks(instance)
|
|
_queue.erase(instance.event_instance_id)
|
|
if not instance.deduplication_key.is_empty():
|
|
_active_deduplication_keys.erase(instance.deduplication_key)
|
|
if completed:
|
|
instance.state = EventInstanceType.State.COMPLETED
|
|
var source := get_definition(instance.source_event_id)
|
|
if source != null:
|
|
var scope_key := _scope_key(source, instance.actor_ids, instance.created_at)
|
|
if not scope_key.is_empty():
|
|
_completed_scope_keys[scope_key] = instance.event_instance_id
|
|
event_finished.emit(instance)
|
|
|
|
|
|
func _cancel_instance(instance: EventInstance, reason: StringName, terminal_state: EventInstance.State) -> void:
|
|
_release_all_reserved_costs(instance, instance.context)
|
|
_release_actor_locks(instance)
|
|
_queue.erase(instance.event_instance_id)
|
|
if not instance.deduplication_key.is_empty():
|
|
_active_deduplication_keys.erase(instance.deduplication_key)
|
|
instance.state = terminal_state
|
|
instance.failure_reason = String(reason)
|
|
event_cancelled.emit(instance, reason)
|
|
|
|
|
|
func _reserve_cost(cost: Dictionary, context: Dictionary) -> Dictionary:
|
|
if cost.is_empty():
|
|
return {&"ok": true, &"token": {}}
|
|
if not _cost_reserver.is_valid():
|
|
return {&"ok": false, &"error": "Choice Cost reserver is not configured."}
|
|
return _normalize_operation_result(_cost_reserver.call(cost.duplicate(true), context))
|
|
|
|
|
|
func _release_reserved_cost(instance: EventInstance, choice_id: StringName, context: Dictionary) -> Dictionary:
|
|
if not instance.reserved_costs.has(choice_id):
|
|
return {&"ok": true}
|
|
var reservation: Dictionary = instance.reserved_costs[choice_id]
|
|
var result := {&"ok": true}
|
|
if _cost_releaser.is_valid():
|
|
result = _normalize_operation_result(_cost_releaser.call(_copy(reservation.get(&"token")), context))
|
|
instance.reserved_costs.erase(choice_id)
|
|
return result
|
|
|
|
|
|
func _release_all_reserved_costs(instance: EventInstance, context: Dictionary) -> void:
|
|
var choice_ids := _sorted_name_keys(instance.reserved_costs)
|
|
for choice_id: StringName in choice_ids:
|
|
_release_reserved_cost(instance, choice_id, context)
|
|
|
|
|
|
func _commit_reserved_cost(instance: EventInstance, choice_id: StringName, context: Dictionary) -> Dictionary:
|
|
if not instance.reserved_costs.has(choice_id):
|
|
return {&"ok": true}
|
|
var reservation: Dictionary = instance.reserved_costs[choice_id]
|
|
var result := {&"ok": true}
|
|
if _cost_committer.is_valid():
|
|
result = _normalize_operation_result(_cost_committer.call(_copy(reservation.get(&"token")), context))
|
|
if bool(result.get(&"ok", false)):
|
|
instance.reserved_costs.erase(choice_id)
|
|
return result
|
|
|
|
|
|
func _cache_choice_result(choice_instance_id: StringName, result: CommandResult) -> void:
|
|
_processed_choices[choice_instance_id] = result.to_dictionary()
|
|
|
|
|
|
func _missing_actor_ids(actor_ids: Array[StringName]) -> Array[StringName]:
|
|
var missing: Array[StringName] = []
|
|
for actor_id: StringName in actor_ids:
|
|
if not _actor_is_available(actor_id):
|
|
missing.append(actor_id)
|
|
return missing
|
|
|
|
|
|
func _actor_is_available(actor_id: StringName) -> bool:
|
|
return bool(_actor_exists.call(actor_id)) if _actor_exists.is_valid() else true
|
|
|
|
|
|
func _merged_context(instance: EventInstance, absolute_minute: int, extra: Dictionary) -> Dictionary:
|
|
var result := instance.context.duplicate(true)
|
|
for raw_key: Variant in extra:
|
|
result[raw_key] = _copy(extra[raw_key])
|
|
result[&"absolute_minute"] = absolute_minute
|
|
result[&"event_instance_id"] = instance.event_instance_id
|
|
result[&"actor_ids"] = instance.actor_ids.duplicate()
|
|
return result
|
|
|
|
|
|
func _is_content_restricted(definition: EventDefinition) -> bool:
|
|
if definition.content_rating > _maximum_content_rating:
|
|
return true
|
|
for tag: StringName in definition.content_tags:
|
|
if _disabled_content_tags.has(tag):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _replacement_cycle_exists(event_id: StringName) -> bool:
|
|
var visited: Dictionary = {}
|
|
var current_id := event_id
|
|
while not current_id.is_empty():
|
|
if visited.has(current_id):
|
|
return true
|
|
visited[current_id] = true
|
|
var definition := get_definition(current_id)
|
|
if definition == null or definition.filter_behavior != EventDefinitionType.FilterBehavior.REPLACE:
|
|
return false
|
|
current_id = definition.replacement_event_id
|
|
return false
|
|
|
|
|
|
func _scope_key(definition: EventDefinition, actor_ids: Array[StringName], absolute_minute: int) -> StringName:
|
|
match definition.once_scope:
|
|
EventDefinitionType.OnceScope.PER_SAVE:
|
|
return StringName("save:%s" % definition.event_id)
|
|
EventDefinitionType.OnceScope.PER_DAY:
|
|
return StringName("day:%s:%d" % [definition.event_id, absolute_minute / 1_440])
|
|
EventDefinitionType.OnceScope.PER_ACTOR_PAIR:
|
|
return StringName("pair:%s:%s" % [definition.event_id, ",".join(_names_to_strings(actor_ids))])
|
|
return &""
|
|
|
|
|
|
func _sort_queue() -> void:
|
|
_queue.sort_custom(
|
|
func(a_id: StringName, b_id: StringName) -> bool:
|
|
var a := get_instance(a_id)
|
|
var b := get_instance(b_id)
|
|
if a == null:
|
|
return false
|
|
if b == null:
|
|
return true
|
|
if a.priority != b.priority:
|
|
return a.priority > b.priority
|
|
var a_expiry := a.expires_at if a.expires_at >= 0 else 9_223_372_036_854_775_000
|
|
var b_expiry := b.expires_at if b.expires_at >= 0 else 9_223_372_036_854_775_000
|
|
if a_expiry != b_expiry:
|
|
return a_expiry < b_expiry
|
|
return String(a.event_instance_id) < String(b.event_instance_id)
|
|
)
|
|
|
|
|
|
static func _default_deduplication_key(event_id: StringName, actor_ids: Array[StringName]) -> StringName:
|
|
return StringName("%s:%s" % [event_id, ",".join(_names_to_strings(actor_ids))])
|
|
|
|
|
|
static func _normalize_operation_result(raw_result: Variant) -> Dictionary:
|
|
if raw_result is Dictionary:
|
|
var result: Dictionary = raw_result.duplicate(true)
|
|
if bool(result.get(&"handled", result.get("handled", true))) == false:
|
|
return {&"ok": false, &"not_handled": true}
|
|
if not result.has(&"ok") and not result.has("ok"):
|
|
result[&"ok"] = true
|
|
return result
|
|
if raw_result is bool:
|
|
return {&"ok": raw_result, &"error": "Operation returned false." if not raw_result else ""}
|
|
if raw_result is String or raw_result is StringName:
|
|
var message := String(raw_result)
|
|
return {&"ok": message.is_empty(), &"error": message}
|
|
if raw_result == null:
|
|
return {&"ok": true}
|
|
return {&"ok": true, &"token": _copy(raw_result)}
|
|
|
|
|
|
static func _enum_value(keys: Array, raw_value: Variant, fallback: int) -> int:
|
|
if raw_value is int:
|
|
return clampi(int(raw_value), 0, keys.size() - 1)
|
|
var index := keys.find(String(raw_value).to_upper())
|
|
return index if index >= 0 else fallback
|
|
|
|
|
|
static func _strict_enum_value(keys: Array, raw_value: Variant) -> int:
|
|
if raw_value is int:
|
|
var numeric := int(raw_value)
|
|
return numeric if numeric >= 0 and numeric < keys.size() else -1
|
|
if raw_value is String or raw_value is StringName:
|
|
return keys.find(String(raw_value).to_upper())
|
|
return -1
|
|
|
|
|
|
static func _sorted_unique_names(raw_value: Variant) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
if raw_value is Array:
|
|
for raw_entry: Variant in raw_value:
|
|
var entry := StringName(raw_entry)
|
|
if not entry.is_empty() and not result.has(entry):
|
|
result.append(entry)
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _sorted_name_keys(source: Dictionary) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for raw_key: Variant in source:
|
|
result.append(StringName(raw_key))
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _names_to_strings(values: Array[StringName]) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for value: StringName in values:
|
|
result.append(String(value))
|
|
return result
|
|
|
|
|
|
static func _string_key_dictionary(source: Dictionary) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
var keys: Array[String] = []
|
|
for raw_key: Variant in source:
|
|
keys.append(String(raw_key))
|
|
keys.sort()
|
|
for key: String in keys:
|
|
var value: Variant = source.get(StringName(key), source.get(key))
|
|
result[key] = _copy(value)
|
|
return result
|
|
|
|
|
|
static func _name_dictionary(raw_value: Variant) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
if raw_value is Dictionary:
|
|
for raw_key: Variant in raw_value:
|
|
result[StringName(raw_key)] = StringName(raw_value[raw_key])
|
|
return result
|
|
|
|
|
|
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
|
if source.has(key):
|
|
return source[key]
|
|
return source.get(String(key), fallback)
|
|
|
|
|
|
static func _array(value: Variant) -> Array:
|
|
return value if value is Array else []
|
|
|
|
|
|
static func _dictionary(value: Variant) -> Dictionary:
|
|
return value if value is Dictionary else {}
|
|
|
|
|
|
static func _copy(value: Variant) -> Variant:
|
|
return value.duplicate(true) if value is Dictionary or value is Array else value
|
|
|
|
|
|
func _success(details: Dictionary = {}) -> CommandResult:
|
|
return CommandResultType.succeeded(0, details)
|
|
|
|
|
|
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
|
|
last_error = message
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.VALIDATION_FAILED,
|
|
0,
|
|
message,
|
|
details
|
|
)
|
|
|
|
|
|
func _not_found(kind: String, entity_id: StringName) -> CommandResult:
|
|
return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id})
|