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.
This commit is contained in:
Takeshi
2026-10-11 15:58:00 +09:00
parent 39f6068461
commit b84bade045
235 changed files with 495831 additions and 312 deletions
+200 -35
View File
@@ -5,6 +5,9 @@ extends RefCounted
## 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)
@@ -47,6 +50,7 @@ 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(
@@ -70,6 +74,19 @@ func configure_callbacks(
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
@@ -222,6 +239,8 @@ func enqueue_candidate(
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,
@@ -256,6 +275,8 @@ func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> C
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,
@@ -277,6 +298,10 @@ func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> C
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)
@@ -295,21 +320,27 @@ func start_next_event(absolute_minute: int, extra_context: Dictionary = {}) -> C
if bool(automatic_result.get(&"fatal", false)):
instance.state = EventInstanceType.State.ERROR
pause_requested = true
_finish_instance(instance, false)
var finish := _finish_instance(instance, false)
if not bool(finish.get(&"ok", false)):
automatic_result[&"cost_release"] = finish
else:
_cancel_instance(instance, &"ACTION_FAILED", EventInstanceType.State.CANCELLED)
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
instance.choice_instance_id = StringName("choice_instance_%08d" % _next_choice_sequence)
_next_choice_sequence += 1
_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,
})
_finish_instance(instance, 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,
@@ -334,10 +365,12 @@ func process_expired(absolute_minute: int) -> Array[Dictionary]:
[],
context
)
_cancel_instance(instance, &"EXPIRED", EventInstanceType.State.EXPIRED)
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
@@ -347,6 +380,18 @@ func reserve_choice_cost(
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:
@@ -360,7 +405,10 @@ func reserve_choice_cost(
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})
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.")
@@ -384,6 +432,19 @@ func submit_choice(
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])
@@ -402,7 +463,10 @@ func submit_choice(
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})
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)
@@ -420,30 +484,61 @@ func submit_choice(
context
)
if not bool(transaction_result.get(&"ok", false)):
_release_reserved_cost(instance, choice_id, context)
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
_finish_instance(instance, false)
_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)):
_rollback_records(transaction_result.get(&"records", []), context)
_release_reserved_cost(instance, choice_id, context)
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
_finish_instance(instance, true)
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,
@@ -482,7 +577,9 @@ func cancel_event(event_instance_id: StringName, reason: StringName = &"CANCELLE
return _not_found("EventInstance", event_instance_id)
if instance.is_terminal():
return _invalid("Event is already terminal.")
_cancel_instance(instance, reason, EventInstanceType.State.CANCELLED)
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})
@@ -765,6 +862,18 @@ func _execute_actions(
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:
@@ -779,11 +888,13 @@ func _execute_actions(
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)):
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)):
@@ -807,18 +918,31 @@ func _run_rollback_all(actions: Array, context: Dictionary) -> 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": []}
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) -> void:
func _rollback_records(raw_records: Variant, context: Dictionary) -> Dictionary:
if not raw_records is Array:
return
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]
_execute_action(record[&"action"], &"rollback", context, record.get(&"token"))
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(
@@ -876,7 +1000,7 @@ func _execute_builtin_action(
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 _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)
@@ -935,7 +1059,10 @@ func _handle_missing_actors(
missing_actor_ids: Array[StringName],
absolute_minute: int
) -> void:
_release_all_reserved_costs(instance, _merged_context(instance, absolute_minute, {}))
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
@@ -995,12 +1122,26 @@ func _release_actor_locks(instance: EventInstance) -> void:
_foreground_instance_id = &""
func _finish_instance(instance: EventInstance, completed: bool) -> void:
_release_all_reserved_costs(instance, instance.context)
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)
@@ -1009,17 +1150,20 @@ func _finish_instance(instance: EventInstance, completed: bool) -> void:
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) -> 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)
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:
@@ -1037,14 +1181,30 @@ func _release_reserved_cost(instance: EventInstance, choice_id: StringName, cont
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)
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) -> 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 _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:
@@ -1256,3 +1416,8 @@ func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
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