Files
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
2026-10-11 15:58:00 +09:00

1424 lines
58 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 effect_transaction_started
signal effect_transaction_finished
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
var _candidate_eligibility_guard := Callable()
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
## A domain-owned, read-only guard runs before queue mutation and again before
## a queued event acquires actors or executes actions. It is rebuilt on restore.
func configure_candidate_eligibility_guard(guard: Callable = Callable()) -> void:
_candidate_eligibility_guard = guard
func _candidate_permitted(definition: EventDefinition, context: Dictionary) -> bool:
if not _candidate_eligibility_guard.is_valid():
return true
var result: Variant = _candidate_eligibility_guard.call(definition, context.duplicate(true))
return typeof(result) == TYPE_BOOL and result
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()
if not _candidate_permitted(source, context) or not _candidate_permitted(definition, context):
return _invalid("Event candidate is prevented by placement protection.", {&"reason": &"PLACEMENT_PROTECTION"})
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 pause_requested:
return _invalid("Event processing stopped after a fatal failure.", {&"fatal": true})
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
var original_definition := get_definition(instance.source_event_id)
if original_definition == null or not _candidate_permitted(original_definition, context) or not _candidate_permitted(definition, context):
_cancel_instance(instance, &"PLACEMENT_PROTECTION", EventInstanceType.State.CANCELLED)
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
var finish := _finish_instance(instance, false)
if not bool(finish.get(&"ok", false)):
automatic_result[&"cost_release"] = finish
else:
var cancelled := _cancel_instance(instance, &"ACTION_FAILED", EventInstanceType.State.CANCELLED)
if not bool(cancelled.get(&"ok", false)):
automatic_result[&"fatal"] = true
automatic_result[&"cost_release"] = cancelled
return _invalid(instance.failure_reason, automatic_result)
if not definition.choices.is_empty():
instance.state = EventInstanceType.State.WAITING_CHOICE
_advance_choice_attempt(instance)
last_error = ""
return _success({
&"event_instance_id": instance.event_instance_id,
&"choice_instance_id": instance.choice_instance_id,
&"waiting_for_choice": true,
})
var finish := _finish_instance(instance, true)
if not bool(finish.get(&"ok", false)):
return _invalid(String(finish.get(&"error", "Event cost release failed.")), finish)
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
)
var cancelled := _cancel_instance(instance, &"EXPIRED", EventInstanceType.State.EXPIRED)
results.append({
&"event_instance_id": instance_id,
&"fallback_ok": bool(fallback_result.get(&"ok", false)),
&"cost_release": cancelled,
&"fatal": bool(cancelled.get(&"fatal", false)),
})
return results
func reserve_choice_cost(
event_instance_id: StringName,
choice_id: StringName,
absolute_minute: int,
extra_context: Dictionary = {}
) -> CommandResult:
effect_transaction_started.emit()
var result := _reserve_choice_cost_inside_transaction(event_instance_id, choice_id, absolute_minute, extra_context)
effect_transaction_finished.emit()
return result
func _reserve_choice_cost_inside_transaction(
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, &"fatal": instance.state == EventInstanceType.State.ERROR,
&"failure_reason": instance.failure_reason,
})
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:
effect_transaction_started.emit()
var result := _submit_choice_inside_transaction(event_instance_id, choice_instance_id, choice_id, absolute_minute, extra_context)
effect_transaction_finished.emit()
return result
func _submit_choice_inside_transaction(
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, &"fatal": instance.state == EventInstanceType.State.ERROR,
&"failure_reason": instance.failure_reason,
})
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)):
var release := _release_reserved_cost(instance, choice_id, context)
if not bool(release.get(&"ok", false)):
transaction_result[&"fatal"] = true
transaction_result[&"cost_release"] = release
transaction_result[&"error"] = "Choice action failed and cost release failed: %s" % release.get(&"error", "")
if bool(transaction_result.get(&"fatal", false)):
var other_release := _release_all_reserved_costs(instance, context, [choice_id])
if not bool(other_release.get(&"ok", false)):
transaction_result[&"other_cost_release"] = other_release
instance.state = EventInstanceType.State.ERROR
instance.failure_reason = String(transaction_result.get(&"error", "Failure branch failed."))
pause_requested = true
_detach_instance(instance)
var failed_result := _invalid(
String(transaction_result.get(&"error", "Choice Action transaction failed.")),
transaction_result
)
_cache_choice_result(choice_instance_id, failed_result)
if instance.state == EventInstanceType.State.WAITING_CHOICE:
_advance_choice_attempt(instance)
return failed_result
var commit_result := _commit_reserved_cost(instance, choice_id, context)
if not bool(commit_result.get(&"ok", false)):
var rollback := _rollback_records(transaction_result.get(&"records", []), context)
var release := _release_reserved_cost(instance, choice_id, context)
if not bool(release.get(&"ok", false)):
commit_result[&"fatal"] = true
commit_result[&"cost_release"] = release
commit_result[&"error"] = "Choice cost commit and release failed: %s" % release.get(&"error", "")
if not bool(rollback.get(&"ok", false)):
commit_result[&"fatal"] = true
commit_result[&"rollback"] = rollback
commit_result[&"error"] = "Choice cost failed and action rollback failed: %s" % rollback.get(&"error", "")
if bool(commit_result.get(&"fatal", false)):
var other_release := _release_all_reserved_costs(instance, context, [choice_id])
if not bool(other_release.get(&"ok", false)):
commit_result[&"other_cost_release"] = other_release
instance.state = EventInstanceType.State.ERROR
instance.failure_reason = String(commit_result[&"error"])
pause_requested = true
_detach_instance(instance)
var commit_failure := _invalid(
String(commit_result.get(&"error", "Choice cost commit failed.")),
commit_result
)
_cache_choice_result(choice_instance_id, commit_failure)
if instance.state == EventInstanceType.State.WAITING_CHOICE:
_advance_choice_attempt(instance)
return commit_failure
instance.selected_choice_id = choice_id
var finish := _finish_instance(instance, true)
if not bool(finish.get(&"ok", false)):
var finish_failure := _invalid(String(finish.get(&"error", "Choice final cost release failed.")), finish)
_cache_choice_result(choice_instance_id, finish_failure)
return finish_failure
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.")
var cancelled := _cancel_instance(instance, reason, EventInstanceType.State.CANCELLED)
if not bool(cancelled.get(&"ok", false)):
return _invalid(String(cancelled.get(&"error", "Event cost release failed.")), 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:
effect_transaction_started.emit()
var result := _execute_actions_inside_transaction(actions, policy, failure_actions, context)
effect_transaction_finished.emit()
return result
func _execute_actions_inside_transaction(
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:
if bool(applied.get(&"fatal", false)):
return applied
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)) or bool(primary.get(&"fatal", 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)):
var rollback := _rollback_records(records, context)
var failed := {&"ok": false, &"error": applied.get(&"error", "Action apply failed."), &"records": []}
if bool(applied.get(&"fatal", false)) or not bool(rollback.get(&"ok", false)):
failed[&"fatal"] = true
failed[&"rollback"] = rollback
failed[&"error"] = "Action transaction rollback failed: %s; %s" % [failed[&"error"], rollback.get(&"error", "")]
return failed
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) -> Dictionary:
if not raw_records is Array:
return {&"ok": false, &"error": "Rollback records are malformed."}
var failed_actions: Array[StringName] = []
var errors: Array[String] = []
# Ask every participant even after a failure; retain reverse apply order.
for index: int in range(raw_records.size() - 1, -1, -1):
var record: Dictionary = raw_records[index]
var action: EventAction = record[&"action"]
var result := _execute_action(action, &"rollback", context, record.get(&"token"))
if not bool(result.get(&"ok", false)):
failed_actions.append(action.action_id)
errors.append(String(result.get(&"error", "Rollback participant returned failure.")))
return {&"ok": failed_actions.is_empty(), &"failed_action_ids": failed_actions, &"error": "; ".join(errors)}
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():
return _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:
var release := _release_all_reserved_costs(instance, _merged_context(instance, absolute_minute, {}))
if not bool(release.get(&"ok", false)):
_mark_release_failure(instance, release)
return
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 _detach_instance(instance: EventInstance) -> void:
_release_actor_locks(instance)
_queue.erase(instance.event_instance_id)
if not instance.deduplication_key.is_empty():
_active_deduplication_keys.erase(instance.deduplication_key)
func _mark_release_failure(instance: EventInstance, result: Dictionary) -> void:
instance.state = EventInstanceType.State.ERROR
instance.failure_reason = String(result.get(&"error", "Event cost release failed."))
pause_requested = true
_detach_instance(instance)
func _finish_instance(instance: EventInstance, completed: bool) -> Dictionary:
var release := _release_all_reserved_costs(instance, instance.context)
if not bool(release.get(&"ok", false)):
_mark_release_failure(instance, release)
return release
_detach_instance(instance)
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)
return release
func _cancel_instance(instance: EventInstance, reason: StringName, terminal_state: EventInstance.State) -> Dictionary:
var release := _release_all_reserved_costs(instance, instance.context)
if not bool(release.get(&"ok", false)):
_mark_release_failure(instance, release)
event_cancelled.emit(instance, &"COST_RELEASE_FAILED")
return release
_detach_instance(instance)
instance.state = terminal_state
instance.failure_reason = String(reason)
event_cancelled.emit(instance, reason)
return release
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))
elif not _dictionary(reservation.get(&"cost", {})).is_empty():
result = {&"ok": false, &"error": "Choice Cost releaser is not configured."}
if bool(result.get(&"ok", false)):
instance.reserved_costs.erase(choice_id)
else:
result[&"fatal"] = true
result[&"failed_choice_id"] = choice_id
return result
func _release_all_reserved_costs(instance: EventInstance, context: Dictionary, excluded_choice_ids: Array[StringName] = []) -> Dictionary:
var failed_ids: Array[StringName] = []
var failures: Array[Dictionary] = []
for choice_id: StringName in _sorted_name_keys(instance.reserved_costs):
if choice_id in excluded_choice_ids:
continue
var released := _release_reserved_cost(instance, choice_id, context)
if not bool(released.get(&"ok", false)):
failed_ids.append(choice_id)
failures.append(released)
if failed_ids.is_empty():
return {&"ok": true}
return {&"ok": false, &"fatal": true, &"failed_choice_ids": failed_ids,
&"failures": failures, &"error": "Event cost release failed for choices: %s" % [failed_ids]}
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})
func _advance_choice_attempt(instance: EventInstance) -> void:
instance.choice_instance_id = StringName("choice_instance_%08d" % _next_choice_sequence)
_next_choice_sequence += 1