feat: plan typed action effects

This commit is contained in:
Rijad Zuzo
2026-08-12 22:29:46 +02:00
parent 88e974eff0
commit af77bbbe65
10 changed files with 950 additions and 0 deletions
@@ -0,0 +1,102 @@
class_name ActionEffectCommitResult
extends RefCounted
var _committed := false
var _rolled_back := false
var _event_ids: Array[int] = []
var _reason_code: StringName
var _message := ""
var _reason_trace: Array[StringName] = []
var _valid := false
func _init(
committed: bool = false,
rolled_back: bool = false,
event_ids: Array = [],
reason_code: StringName = &"",
message: String = "",
reason_trace: Array = []
) -> void:
_committed = committed
_rolled_back = rolled_back
_reason_code = reason_code
_message = message
_valid = true
var seen_event_ids := {}
for event_id_value in event_ids:
if event_id_value is not int or int(event_id_value) < 0:
_valid = false
continue
var event_id := int(event_id_value)
if seen_event_ids.has(event_id):
_valid = false
seen_event_ids[event_id] = true
_event_ids.append(event_id)
for trace_value in reason_trace:
if trace_value is not String and trace_value is not StringName:
_valid = false
continue
var trace_id := StringName(trace_value)
if trace_id.is_empty():
_valid = false
continue
_reason_trace.append(trace_id)
if _reason_trace.is_empty():
_valid = false
if _committed:
_valid = (
_valid
and not _rolled_back
and not _event_ids.is_empty()
and _reason_code.is_empty()
and _message.is_empty()
)
else:
_valid = (
_valid
and _rolled_back
and _event_ids.is_empty()
and not _reason_code.is_empty()
and not _message.is_empty()
)
static func committed(
event_ids: Array, reason_trace: Array = [&"whole_plan_committed"]
) -> ActionEffectCommitResult:
return ActionEffectCommitResult.new(true, false, event_ids, &"", "", reason_trace)
static func rollback_safe_failure(
reason_code: StringName, message: String, reason_trace: Array
) -> ActionEffectCommitResult:
return ActionEffectCommitResult.new(false, true, [], reason_code, message, reason_trace)
func is_valid() -> bool:
return _valid
func did_commit() -> bool:
return _committed
func was_rolled_back() -> bool:
return _rolled_back
func get_event_ids() -> Array[int]:
return _event_ids.duplicate()
func get_reason_code() -> StringName:
return _reason_code
func get_message() -> String:
return _message
func get_reason_trace() -> Array[StringName]:
return _reason_trace.duplicate()
@@ -0,0 +1 @@
uid://df1w0x8o3k3i4
+277
View File
@@ -0,0 +1,277 @@
class_name ActionEffectPlan
extends RefCounted
const SCHEMA_VERSION := 1
const MAX_PRIMITIVE_DEPTH := 16
const MAX_COLLECTION_SIZE := 4096
const RECORD_FIELDS := ["schema_version", "action_id", "operations", "reason_trace"]
const OPERATION_FIELDS := [
"operation_id", "effect_id", "operation_kind", "authored_order", "payload"
]
var _action_id: StringName
var _operations: Array[Dictionary] = []
var _reason_trace: Array[StringName] = []
var _errors: Array[String] = []
func _init(action_id: StringName = &"", operations: Array = [], reason_trace: Array = []) -> void:
_action_id = action_id
if _action_id.is_empty():
_errors.append("action_id is empty")
if operations.is_empty():
_errors.append("operations is empty")
if reason_trace.is_empty():
_errors.append("reason_trace is empty")
_validate_reason_trace(reason_trace)
var operation_ids := {}
var effect_ids := {}
for operation_index in operations.size():
var raw_operation: Variant = operations[operation_index]
if raw_operation is not Dictionary:
_errors.append("operation %d is not a dictionary" % operation_index)
continue
if not is_primitive_record(raw_operation):
_errors.append("operation %d contains a non-primitive value" % operation_index)
continue
var operation := _canonicalize(raw_operation) as Dictionary
_validate_operation(operation, operation_index)
var raw_operation_id: Variant = operation.get("operation_id", "")
var raw_effect_id: Variant = operation.get("effect_id", "")
var operation_id := StringName(raw_operation_id) if raw_operation_id is String else &""
var effect_id := StringName(raw_effect_id) if raw_effect_id is String else &""
if not operation_id.is_empty():
if operation_ids.has(operation_id):
_errors.append("duplicate operation_id '%s'" % operation_id)
operation_ids[operation_id] = true
if not effect_id.is_empty():
if effect_ids.has(effect_id):
_errors.append("duplicate effect_id '%s'" % effect_id)
effect_ids[effect_id] = true
_operations.append(operation)
_errors.sort()
static func from_dictionary(record: Dictionary) -> ActionEffectPlan:
if not is_primitive_record(record):
return null
var canonical := _canonicalize(record) as Dictionary
if not _has_exact_fields(canonical, RECORD_FIELDS):
return null
if canonical["schema_version"] is not int or int(canonical["schema_version"]) != SCHEMA_VERSION:
return null
if canonical["action_id"] is not String or String(canonical["action_id"]).is_empty():
return null
if canonical["operations"] is not Array or canonical["reason_trace"] is not Array:
return null
var plan := ActionEffectPlan.new(
StringName(canonical["action_id"]), canonical["operations"], canonical["reason_trace"]
)
return plan if plan.is_valid() else null
func is_valid() -> bool:
return _errors.is_empty()
func get_errors() -> Array[String]:
return _errors.duplicate()
func get_action_id() -> StringName:
return _action_id
func get_operations() -> Array[Dictionary]:
return _operations.duplicate(true)
func get_operation_count() -> int:
return _operations.size()
func get_reason_trace() -> Array[StringName]:
return _reason_trace.duplicate()
func to_dictionary() -> Dictionary:
var trace: Array[String] = []
for trace_id in _reason_trace:
trace.append(String(trace_id))
return {
"schema_version": SCHEMA_VERSION,
"action_id": String(_action_id),
"operations": _operations.duplicate(true),
"reason_trace": trace,
}
static func is_primitive_record(value: Variant, depth: int = 0) -> bool:
if depth > MAX_PRIMITIVE_DEPTH:
return false
if value == null or value is bool or value is int or value is String or value is StringName:
return true
if value is float:
return is_finite(value)
if value is Array:
if value.size() > MAX_COLLECTION_SIZE:
return false
for item in value:
if not is_primitive_record(item, depth + 1):
return false
return true
if value is Dictionary:
if value.size() > MAX_COLLECTION_SIZE:
return false
var normalized_keys := {}
for key in value:
if key is not String and key is not StringName:
return false
var normalized_key := String(key)
if normalized_key.is_empty() or normalized_keys.has(normalized_key):
return false
normalized_keys[normalized_key] = true
if not is_primitive_record(value[key], depth + 1):
return false
return true
return false
func _validate_reason_trace(reason_trace: Array) -> void:
for trace_value in reason_trace:
if trace_value is not String and trace_value is not StringName:
_errors.append("reason_trace contains a non-string value")
continue
var trace_id := StringName(trace_value)
if trace_id.is_empty():
_errors.append("reason_trace contains an empty ID")
continue
_reason_trace.append(trace_id)
func _validate_operation(operation: Dictionary, operation_index: int) -> void:
if not _has_exact_fields(operation, OPERATION_FIELDS):
_errors.append("operation %d has an invalid field set" % operation_index)
return
var operation_id := (
StringName(operation["operation_id"]) if operation["operation_id"] is String else &""
)
var effect_id := StringName(operation["effect_id"]) if operation["effect_id"] is String else &""
var operation_kind := (
StringName(operation["operation_kind"]) if operation["operation_kind"] is String else &""
)
if operation_id.is_empty():
_errors.append("operation %d has an empty operation_id" % operation_index)
if effect_id.is_empty():
_errors.append("operation %d has an empty effect_id" % operation_index)
if operation_kind not in ActionEffect.VALID_KINDS:
_errors.append("operation %d has unknown kind '%s'" % [operation_index, operation_kind])
if (
operation["authored_order"] is not int
or int(operation["authored_order"]) != operation_index
):
_errors.append("operation %d does not preserve authored_order" % operation_index)
if operation["payload"] is not Dictionary:
_errors.append("operation %d payload is not a dictionary" % operation_index)
return
_validate_payload(operation_kind, operation["payload"], operation_index)
func _validate_payload(kind: StringName, payload: Dictionary, operation_index: int) -> void:
var expected_fields := _payload_fields(kind)
if expected_fields.is_empty() or not _has_exact_fields(payload, expected_fields):
_errors.append("operation %d payload has an invalid field set" % operation_index)
return
if payload["parameters"] is not Dictionary:
_errors.append("operation %d parameters are not a dictionary" % operation_index)
if (
kind
in [
ActionEffect.KIND_METRIC_DELTA,
ActionEffect.KIND_NEED_DELTA,
ActionEffect.KIND_RELATIONSHIP_DELTA,
]
):
_validate_nonempty_id(payload["subject_key"], "subject_key", operation_index)
_validate_finite_number(payload["delta"], "delta", operation_index)
return
match kind:
ActionEffect.KIND_INVENTORY_TRANSFER:
_validate_nonempty_id(payload["item_id"], "item_id", operation_index)
_validate_positive_number(payload["amount"], "amount", operation_index)
_validate_nonempty_id(payload["source_role"], "source_role", operation_index)
_validate_nonempty_id(payload["destination_role"], "destination_role", operation_index)
if String(payload["source_role"]) == String(payload["destination_role"]):
_errors.append("operation %d transfer roles must differ" % operation_index)
ActionEffect.KIND_DAMAGE, ActionEffect.KIND_HEALING:
_validate_positive_number(payload["amount"], "amount", operation_index)
ActionEffect.KIND_SCHEDULE_ACTION:
_validate_nonempty_id(
payload["scheduled_action_id"], "scheduled_action_id", operation_index
)
ActionEffect.KIND_SITE_CONDITION:
_validate_nonempty_id(payload["subject_key"], "subject_key", operation_index)
_validate_finite_number(payload["value"], "value", operation_index)
func _validate_nonempty_id(value: Variant, field: String, operation_index: int) -> void:
if (value is not String and value is not StringName) or String(value).is_empty():
_errors.append("operation %d has invalid %s" % [operation_index, field])
func _validate_finite_number(value: Variant, field: String, operation_index: int) -> void:
if (value is not int and value is not float) or not is_finite(float(value)):
_errors.append("operation %d has invalid %s" % [operation_index, field])
func _validate_positive_number(value: Variant, field: String, operation_index: int) -> void:
_validate_finite_number(value, field, operation_index)
if (value is int or value is float) and float(value) <= 0.0:
_errors.append("operation %d requires positive %s" % [operation_index, field])
static func _payload_fields(kind: StringName) -> Array:
if (
kind
in [
ActionEffect.KIND_METRIC_DELTA,
ActionEffect.KIND_NEED_DELTA,
ActionEffect.KIND_RELATIONSHIP_DELTA,
]
):
return ["subject_key", "delta", "parameters"]
match kind:
ActionEffect.KIND_INVENTORY_TRANSFER:
return ["item_id", "amount", "source_role", "destination_role", "parameters"]
ActionEffect.KIND_DAMAGE, ActionEffect.KIND_HEALING:
return ["amount", "parameters"]
ActionEffect.KIND_SCHEDULE_ACTION:
return ["scheduled_action_id", "parameters"]
ActionEffect.KIND_SITE_CONDITION:
return ["subject_key", "value", "parameters"]
return []
static func _has_exact_fields(record: Dictionary, fields: Array) -> bool:
if record.size() != fields.size():
return false
for field in fields:
if not record.has(field):
return false
return true
static func _canonicalize(value: Variant) -> Variant:
if value is StringName:
return String(value)
if value is Array:
var result: Array = []
for item in value:
result.append(_canonicalize(item))
return result
if value is Dictionary:
var result := {}
for key in value:
result[String(key)] = _canonicalize(value[key])
return result
return value
@@ -0,0 +1 @@
uid://b7a6qjmsnhq1c
+176
View File
@@ -0,0 +1,176 @@
class_name ActionEffectPlanner
extends RefCounted
enum Strategy {
METRIC_DELTA,
NEED_DELTA,
INVENTORY_TRANSFER,
DAMAGE,
HEALING,
RELATIONSHIP_DELTA,
SCHEDULE_ACTION,
SITE_CONDITION,
}
var _strategies_by_kind: Dictionary = {}
var _last_errors: Array[String] = []
var _last_reason_trace: Array[StringName] = []
func _init(register_default_strategies: bool = true) -> void:
if register_default_strategies:
for effect_kind in ActionEffect.VALID_KINDS:
register_builtin_strategy(effect_kind)
func register_builtin_strategy(effect_kind: StringName) -> bool:
var strategy := _builtin_strategy(effect_kind)
if strategy < 0:
return false
_strategies_by_kind[effect_kind] = strategy
return true
func unregister_strategy(effect_kind: StringName) -> bool:
return _strategies_by_kind.erase(effect_kind)
func has_strategy(effect_kind: StringName) -> bool:
return _strategies_by_kind.has(effect_kind)
func get_last_errors() -> Array[String]:
return _last_errors.duplicate()
func get_last_reason_trace() -> Array[StringName]:
return _last_reason_trace.duplicate()
func plan(definition: ActionDefinition, actor: WorldEntityRef) -> ActionEffectPlan:
_last_errors.clear()
_last_reason_trace = [&"planning_started"]
if not _actor_is_supported(actor):
return _fail("actor must be a player or person reference")
_last_reason_trace.append(&"shared_actor_contract_validated")
if definition == null:
return _fail("action definition is missing")
var definition_errors := definition.validate()
if not definition_errors.is_empty():
for error in definition_errors:
_last_errors.append("ActionDefinition: " + error)
return _finish_failure()
if definition.effects.is_empty():
return _fail("action definition has no effects")
_last_reason_trace.append(&"action_definition_validated")
var operations: Array[Dictionary] = []
var seen_effect_ids := {}
for authored_order in definition.effects.size():
var effect := definition.effects[authored_order] as ActionEffect
if effect == null:
return _fail("effect %d is not an ActionEffect" % authored_order)
if seen_effect_ids.has(effect.effect_id):
return _fail("duplicate effect_id '%s'" % effect.effect_id)
seen_effect_ids[effect.effect_id] = true
if not _strategies_by_kind.has(effect.effect_kind):
return _fail("missing strategy for effect kind '%s'" % effect.effect_kind)
if not ActionEffectPlan.is_primitive_record(effect.parameters):
return _fail("effect '%s' parameters are not primitive" % effect.effect_id)
var operation := _plan_effect(effect, authored_order)
if operation.is_empty():
return _fail("strategy failed for effect '%s'" % effect.effect_id)
operations.append(operation)
_last_reason_trace.append(StringName("strategy_%s_planned" % effect.effect_kind))
_last_reason_trace.append(&"authored_order_preserved")
var plan_trace := _last_reason_trace.duplicate()
plan_trace.append(&"plan_validated")
var result := ActionEffectPlan.new(definition.action_id, operations, plan_trace)
if not result.is_valid():
_last_errors.append_array(result.get_errors())
return _finish_failure()
_last_reason_trace = result.get_reason_trace()
return result
func _plan_effect(effect: ActionEffect, authored_order: int) -> Dictionary:
var strategy := int(_strategies_by_kind.get(effect.effect_kind, -1))
var payload := {}
match strategy:
Strategy.METRIC_DELTA, Strategy.NEED_DELTA, Strategy.RELATIONSHIP_DELTA:
payload = {
"subject_key": String(effect.subject_key),
"delta": effect.value,
"parameters": effect.parameters.duplicate(true),
}
Strategy.INVENTORY_TRANSFER:
payload = {
"item_id": String(effect.item_id),
"amount": effect.amount,
"source_role": String(effect.source_role),
"destination_role": String(effect.destination_role),
"parameters": effect.parameters.duplicate(true),
}
Strategy.DAMAGE, Strategy.HEALING:
payload = {"amount": effect.amount, "parameters": effect.parameters.duplicate(true)}
Strategy.SCHEDULE_ACTION:
payload = {
"scheduled_action_id": String(effect.scheduled_action_id),
"parameters": effect.parameters.duplicate(true),
}
Strategy.SITE_CONDITION:
payload = {
"subject_key": String(effect.subject_key),
"value": effect.value,
"parameters": effect.parameters.duplicate(true),
}
_:
return {}
return {
"operation_id": "%s:%03d" % [effect.effect_id, authored_order],
"effect_id": String(effect.effect_id),
"operation_kind": String(effect.effect_kind),
"authored_order": authored_order,
"payload": payload,
}
func _fail(message: String) -> ActionEffectPlan:
_last_errors.append(message)
return _finish_failure()
func _finish_failure() -> ActionEffectPlan:
_last_errors.sort()
_last_reason_trace.append(&"planning_failed_closed")
return null
static func _actor_is_supported(actor: WorldEntityRef) -> bool:
return (
actor != null
and actor.is_valid()
and actor.get_entity_type() in [SimulationIds.ENTITY_PLAYER, SimulationIds.ENTITY_PERSON]
)
static func _builtin_strategy(effect_kind: StringName) -> int:
match effect_kind:
ActionEffect.KIND_METRIC_DELTA:
return Strategy.METRIC_DELTA
ActionEffect.KIND_NEED_DELTA:
return Strategy.NEED_DELTA
ActionEffect.KIND_INVENTORY_TRANSFER:
return Strategy.INVENTORY_TRANSFER
ActionEffect.KIND_DAMAGE:
return Strategy.DAMAGE
ActionEffect.KIND_HEALING:
return Strategy.HEALING
ActionEffect.KIND_RELATIONSHIP_DELTA:
return Strategy.RELATIONSHIP_DELTA
ActionEffect.KIND_SCHEDULE_ACTION:
return Strategy.SCHEDULE_ACTION
ActionEffect.KIND_SITE_CONDITION:
return Strategy.SITE_CONDITION
return -1
@@ -0,0 +1 @@
uid://c8pxhf0fed0m1
@@ -0,0 +1,56 @@
class_name TransactionalActionEffectCommitter
extends RefCounted
const REASON_INVALID_PLAN := &"invalid_effect_plan"
const REASON_INVALID_CONTEXT := &"invalid_commit_context"
const REASON_NOT_IMPLEMENTED := &"effect_committer_not_implemented"
const REASON_INVALID_RESULT := &"invalid_commit_result"
# This is the single public commit boundary. Implementations override only
# _commit_whole_plan, apply every operation within one authority transaction,
# and return success only after that transaction records its exact event IDs.
# Any failure result promises that all partial writes were rolled back.
func commit(plan: ActionEffectPlan, commit_context: Dictionary = {}) -> ActionEffectCommitResult:
if plan == null or not plan.is_valid():
return (
ActionEffectCommitResult
. rollback_safe_failure(
REASON_INVALID_PLAN,
"Effect plan is missing or invalid",
[&"commit_rejected_before_authority", &"no_mutation_attempted"],
)
)
if not ActionEffectPlan.is_primitive_record(commit_context):
return (
ActionEffectCommitResult
. rollback_safe_failure(
REASON_INVALID_CONTEXT,
"Commit context must contain only primitive values",
[&"commit_rejected_before_authority", &"no_mutation_attempted"],
)
)
var result := _commit_whole_plan(plan, commit_context.duplicate(true))
if result == null or not result.is_valid():
return (
ActionEffectCommitResult
. rollback_safe_failure(
REASON_INVALID_RESULT,
"Atomic committer returned an invalid result",
[&"commit_result_invalid", &"rollback_required"],
)
)
return result
func _commit_whole_plan(
_plan: ActionEffectPlan, _commit_context: Dictionary
) -> ActionEffectCommitResult:
return (
ActionEffectCommitResult
. rollback_safe_failure(
REASON_NOT_IMPLEMENTED,
"No authoritative action-effect committer is configured",
[&"commit_not_implemented", &"no_mutation_attempted"],
)
)
@@ -0,0 +1 @@
uid://bqt868pi0usxo