feat: plan typed action effects
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,334 @@
|
||||
extends GutTest
|
||||
|
||||
|
||||
class RecordingCommitter:
|
||||
extends TransactionalActionEffectCommitter
|
||||
|
||||
var call_count := 0
|
||||
var operation_count := 0
|
||||
var should_fail := false
|
||||
|
||||
func _commit_whole_plan(
|
||||
plan: ActionEffectPlan, _commit_context: Dictionary
|
||||
) -> ActionEffectCommitResult:
|
||||
call_count += 1
|
||||
operation_count = plan.get_operation_count()
|
||||
if should_fail:
|
||||
return (
|
||||
ActionEffectCommitResult
|
||||
. rollback_safe_failure(
|
||||
&"authority_rejected",
|
||||
"Atomic authority rejected the plan",
|
||||
[&"transaction_started", &"transaction_rolled_back"],
|
||||
)
|
||||
)
|
||||
return ActionEffectCommitResult.committed(
|
||||
[101, 104], [&"transaction_started", &"whole_plan_committed"]
|
||||
)
|
||||
|
||||
|
||||
class InvalidResultCommitter:
|
||||
extends TransactionalActionEffectCommitter
|
||||
|
||||
var call_count := 0
|
||||
|
||||
func _commit_whole_plan(
|
||||
_plan: ActionEffectPlan, _commit_context: Dictionary
|
||||
) -> ActionEffectCommitResult:
|
||||
call_count += 1
|
||||
return ActionEffectCommitResult.new()
|
||||
|
||||
|
||||
func test_all_builtin_strategies_emit_exact_primitive_records_in_authored_order() -> void:
|
||||
var definition := _composite_definition()
|
||||
var planner := ActionEffectPlanner.new()
|
||||
var player_plan := planner.plan(definition, _player_ref())
|
||||
var person_plan := planner.plan(definition, _person_ref())
|
||||
|
||||
assert_not_null(player_plan)
|
||||
assert_not_null(person_plan)
|
||||
assert_true(player_plan.is_valid(), "%s" % [player_plan.get_errors()])
|
||||
assert_eq(
|
||||
player_plan.to_dictionary(),
|
||||
person_plan.to_dictionary(),
|
||||
"Player and person actors should share one effect-planning path",
|
||||
)
|
||||
assert_eq(
|
||||
_operation_kinds(player_plan),
|
||||
[
|
||||
ActionEffect.KIND_METRIC_DELTA,
|
||||
ActionEffect.KIND_NEED_DELTA,
|
||||
ActionEffect.KIND_INVENTORY_TRANSFER,
|
||||
ActionEffect.KIND_DAMAGE,
|
||||
ActionEffect.KIND_HEALING,
|
||||
ActionEffect.KIND_RELATIONSHIP_DELTA,
|
||||
ActionEffect.KIND_SCHEDULE_ACTION,
|
||||
ActionEffect.KIND_SITE_CONDITION,
|
||||
],
|
||||
)
|
||||
var operations := player_plan.get_operations()
|
||||
for operation_index in operations.size():
|
||||
assert_eq(operations[operation_index]["authored_order"], operation_index)
|
||||
assert_eq(operations[0]["payload"]["subject_key"], "safety")
|
||||
assert_eq(operations[0]["payload"]["delta"], 2.5)
|
||||
assert_eq(operations[1]["payload"]["subject_key"], "hunger")
|
||||
assert_eq(operations[1]["payload"]["delta"], -10.0)
|
||||
assert_eq(
|
||||
operations[2]["payload"],
|
||||
{
|
||||
"item_id": "food",
|
||||
"amount": 2.0,
|
||||
"source_role": "actor",
|
||||
"destination_role": "target",
|
||||
"parameters": {"exact": true},
|
||||
},
|
||||
)
|
||||
assert_eq(operations[3]["payload"]["amount"], 12.0)
|
||||
assert_eq(operations[4]["payload"]["amount"], 5.0)
|
||||
assert_eq(operations[5]["payload"]["subject_key"], "trust")
|
||||
assert_eq(operations[6]["payload"]["scheduled_action_id"], "rest")
|
||||
assert_eq(operations[7]["payload"]["subject_key"], "fire_lit")
|
||||
assert_has(player_plan.get_reason_trace(), &"authored_order_preserved")
|
||||
assert_has(player_plan.get_reason_trace(), &"plan_validated")
|
||||
|
||||
|
||||
func test_plan_is_defensively_copied_and_round_trips_as_primitive_data() -> void:
|
||||
var definition := _composite_definition()
|
||||
var plan := ActionEffectPlanner.new().plan(definition, _player_ref())
|
||||
var saved := plan.to_dictionary()
|
||||
assert_true(ActionEffectPlan.is_primitive_record(saved))
|
||||
var restored := ActionEffectPlan.from_dictionary(saved)
|
||||
assert_not_null(restored)
|
||||
assert_eq(restored.to_dictionary(), saved)
|
||||
|
||||
var caller_operations := plan.get_operations()
|
||||
caller_operations[0]["payload"]["delta"] = 999.0
|
||||
definition.effects[0].parameters["scope"] = "mutated"
|
||||
definition.effects[0].value = -500.0
|
||||
assert_eq(
|
||||
plan.to_dictionary(), saved, "Plan values should not alias callers or authored resources"
|
||||
)
|
||||
assert_null(
|
||||
(
|
||||
ActionEffectPlan
|
||||
. from_dictionary(
|
||||
{
|
||||
"schema_version": ActionEffectPlan.SCHEMA_VERSION,
|
||||
"action_id": "bad",
|
||||
"operations": saved["operations"],
|
||||
"reason_trace": [],
|
||||
"reflection_expression": "state.call(method)",
|
||||
}
|
||||
)
|
||||
),
|
||||
"Unknown record fields should fail instead of becoming a reflection DSL",
|
||||
)
|
||||
var malformed := saved.duplicate(true)
|
||||
malformed["operations"][0]["operation_id"] = 17
|
||||
assert_null(
|
||||
ActionEffectPlan.from_dictionary(malformed),
|
||||
"Malformed primitive field types should fail without coercion",
|
||||
)
|
||||
|
||||
|
||||
func test_missing_unknown_and_duplicate_strategies_fail_closed() -> void:
|
||||
var definition := _composite_definition()
|
||||
var planner := ActionEffectPlanner.new()
|
||||
assert_true(planner.unregister_strategy(ActionEffect.KIND_DAMAGE))
|
||||
assert_null(planner.plan(definition, _player_ref()))
|
||||
assert_true(_has_error(planner.get_last_errors(), "missing strategy for effect kind 'damage'"))
|
||||
assert_has(planner.get_last_reason_trace(), &"planning_failed_closed")
|
||||
assert_true(planner.register_builtin_strategy(ActionEffect.KIND_DAMAGE))
|
||||
assert_not_null(planner.plan(definition, _player_ref()))
|
||||
assert_false(planner.register_builtin_strategy(&"unknown_effect_kind"))
|
||||
|
||||
var unknown := _single_effect_definition(
|
||||
_effect(&"unknown", &"unknown_effect_kind", &"field", 1.0)
|
||||
)
|
||||
assert_null(planner.plan(unknown, _player_ref()))
|
||||
assert_true(_has_error(planner.get_last_errors(), "effect_kind 'unknown_effect_kind'"))
|
||||
|
||||
var duplicate := _single_effect_definition(
|
||||
_effect(&"duplicate", ActionEffect.KIND_METRIC_DELTA, &"safety", 1.0)
|
||||
)
|
||||
duplicate.effects.append(_effect(&"duplicate", ActionEffect.KIND_NEED_DELTA, &"energy", -1.0))
|
||||
assert_null(planner.plan(duplicate, _player_ref()))
|
||||
assert_true(_has_error(planner.get_last_errors(), "duplicate effect_id 'duplicate'"))
|
||||
assert_null(planner.plan(definition, WorldEntityRef.create(&"world", &"one")))
|
||||
assert_true(_has_error(planner.get_last_errors(), "actor must be a player or person"))
|
||||
|
||||
|
||||
func test_plan_validation_rejects_duplicate_ids_order_changes_and_hidden_fields() -> void:
|
||||
var valid_plan := ActionEffectPlanner.new().plan(_composite_definition(), _player_ref())
|
||||
var operations := valid_plan.get_operations()
|
||||
operations[1]["operation_id"] = operations[0]["operation_id"]
|
||||
var duplicate_operation := ActionEffectPlan.new(&"composite", operations, [&"test"])
|
||||
assert_false(duplicate_operation.is_valid())
|
||||
assert_true(_has_error(duplicate_operation.get_errors(), "duplicate operation_id"))
|
||||
|
||||
operations = valid_plan.get_operations()
|
||||
operations[1]["effect_id"] = operations[0]["effect_id"]
|
||||
var duplicate_effect := ActionEffectPlan.new(&"composite", operations, [&"test"])
|
||||
assert_false(duplicate_effect.is_valid())
|
||||
assert_true(_has_error(duplicate_effect.get_errors(), "duplicate effect_id"))
|
||||
|
||||
operations = valid_plan.get_operations()
|
||||
operations[2]["authored_order"] = 4
|
||||
var reordered := ActionEffectPlan.new(&"composite", operations, [&"test"])
|
||||
assert_false(reordered.is_valid())
|
||||
assert_true(_has_error(reordered.get_errors(), "does not preserve authored_order"))
|
||||
|
||||
operations = valid_plan.get_operations()
|
||||
operations[0]["payload"]["dynamic_method"] = "apply_anything"
|
||||
var hidden_field := ActionEffectPlan.new(&"composite", operations, [&"test"])
|
||||
assert_false(hidden_field.is_valid())
|
||||
assert_true(_has_error(hidden_field.get_errors(), "payload has an invalid field set"))
|
||||
|
||||
|
||||
func test_runtime_objects_callables_and_node_paths_are_rejected_from_parameters() -> void:
|
||||
var runtime_node := Node.new()
|
||||
var forbidden_values: Array = [
|
||||
runtime_node,
|
||||
Resource.new(),
|
||||
Callable(self, "_player_ref"),
|
||||
NodePath("../Authority"),
|
||||
]
|
||||
for forbidden in forbidden_values:
|
||||
var effect := _effect(
|
||||
&"unsafe", ActionEffect.KIND_METRIC_DELTA, &"safety", 1.0, {"unsafe": forbidden}
|
||||
)
|
||||
var planner := ActionEffectPlanner.new()
|
||||
assert_null(planner.plan(_single_effect_definition(effect), _player_ref()))
|
||||
assert_true(
|
||||
_has_error(planner.get_last_errors(), "parameters"),
|
||||
"Forbidden parameter should fail: %s" % type_string(typeof(forbidden)),
|
||||
)
|
||||
runtime_node.free()
|
||||
|
||||
|
||||
func test_transactional_committer_invokes_one_whole_plan_and_reports_exact_outcome() -> void:
|
||||
var plan := ActionEffectPlanner.new().plan(_composite_definition(), _person_ref())
|
||||
var committer := RecordingCommitter.new()
|
||||
var committed := committer.commit(plan, {"command_id": "command_1", "revision": 7})
|
||||
assert_true(committed.is_valid())
|
||||
assert_true(committed.did_commit())
|
||||
assert_false(committed.was_rolled_back())
|
||||
assert_eq(committed.get_event_ids(), [101, 104])
|
||||
assert_eq(committer.call_count, 1)
|
||||
assert_eq(committer.operation_count, plan.get_operation_count())
|
||||
|
||||
committer.should_fail = true
|
||||
var rejected := committer.commit(plan, {"command_id": "command_2", "revision": 8})
|
||||
assert_true(rejected.is_valid())
|
||||
assert_false(rejected.did_commit())
|
||||
assert_true(rejected.was_rolled_back())
|
||||
assert_true(rejected.get_event_ids().is_empty())
|
||||
assert_eq(rejected.get_reason_code(), &"authority_rejected")
|
||||
assert_eq(committer.call_count, 2, "One commit request should make one atomic authority call")
|
||||
|
||||
var runtime_node := Node.new()
|
||||
var invalid_context := committer.commit(plan, {"authority_node": runtime_node})
|
||||
assert_eq(invalid_context.get_reason_code(), &"invalid_commit_context")
|
||||
assert_true(invalid_context.was_rolled_back())
|
||||
assert_eq(committer.call_count, 2, "Invalid context must fail before the authority callback")
|
||||
runtime_node.free()
|
||||
|
||||
var invalid_committer := InvalidResultCommitter.new()
|
||||
var invalid_result := invalid_committer.commit(plan)
|
||||
assert_eq(invalid_committer.call_count, 1)
|
||||
assert_eq(invalid_result.get_reason_code(), &"invalid_commit_result")
|
||||
assert_true(invalid_result.was_rolled_back())
|
||||
assert_false(ActionEffectCommitResult.committed([4, 4]).is_valid())
|
||||
assert_true(TransactionalActionEffectCommitter.new().commit(plan).was_rolled_back())
|
||||
|
||||
|
||||
func _composite_definition() -> ActionDefinition:
|
||||
var definition := ActionDefinition.new()
|
||||
definition.action_id = &"composite_action"
|
||||
definition.display_name = "Composite action"
|
||||
definition.target_type = SimulationIds.TARGET_ACTIVITY
|
||||
var inventory := _effect(
|
||||
&"03_transfer", ActionEffect.KIND_INVENTORY_TRANSFER, &"", 0.0, {"exact": true}
|
||||
)
|
||||
inventory.item_id = SimulationIds.RESOURCE_FOOD
|
||||
inventory.amount = 2.0
|
||||
inventory.source_role = &"actor"
|
||||
inventory.destination_role = &"target"
|
||||
var damage := _effect(&"04_damage", ActionEffect.KIND_DAMAGE)
|
||||
damage.amount = 12.0
|
||||
damage.parameters = {"damage_type": "physical"}
|
||||
var healing := _effect(&"05_healing", ActionEffect.KIND_HEALING)
|
||||
healing.amount = 5.0
|
||||
var schedule := _effect(&"07_schedule", ActionEffect.KIND_SCHEDULE_ACTION)
|
||||
schedule.scheduled_action_id = SimulationIds.ACTION_REST
|
||||
schedule.parameters = {"delay_ticks": 4}
|
||||
definition.effects = [
|
||||
_effect(
|
||||
&"01_metric",
|
||||
ActionEffect.KIND_METRIC_DELTA,
|
||||
&"safety",
|
||||
2.5,
|
||||
{"scope": "village"},
|
||||
),
|
||||
_effect(&"02_need", ActionEffect.KIND_NEED_DELTA, &"hunger", -10.0),
|
||||
inventory,
|
||||
damage,
|
||||
healing,
|
||||
_effect(
|
||||
&"06_relationship",
|
||||
ActionEffect.KIND_RELATIONSHIP_DELTA,
|
||||
&"trust",
|
||||
0.25,
|
||||
{"other_role": "target"},
|
||||
),
|
||||
schedule,
|
||||
_effect(&"08_site", ActionEffect.KIND_SITE_CONDITION, &"fire_lit", 1.0),
|
||||
]
|
||||
return definition
|
||||
|
||||
|
||||
func _single_effect_definition(effect: ActionEffect) -> ActionDefinition:
|
||||
var definition := ActionDefinition.new()
|
||||
definition.action_id = &"test_action"
|
||||
definition.display_name = "Test action"
|
||||
definition.target_type = SimulationIds.TARGET_ACTIVITY
|
||||
definition.effects = [effect]
|
||||
return definition
|
||||
|
||||
|
||||
func _effect(
|
||||
effect_id: StringName,
|
||||
effect_kind: StringName,
|
||||
subject_key: StringName = &"",
|
||||
value: float = 0.0,
|
||||
parameters: Dictionary = {}
|
||||
) -> ActionEffect:
|
||||
var effect := ActionEffect.new()
|
||||
effect.effect_id = effect_id
|
||||
effect.effect_kind = effect_kind
|
||||
effect.subject_key = subject_key
|
||||
effect.value = value
|
||||
effect.parameters = parameters
|
||||
return effect
|
||||
|
||||
|
||||
func _player_ref() -> WorldEntityRef:
|
||||
return WorldEntityRef.create(SimulationIds.ENTITY_PLAYER, &"player")
|
||||
|
||||
|
||||
func _person_ref() -> WorldEntityRef:
|
||||
return WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"7")
|
||||
|
||||
|
||||
func _operation_kinds(plan: ActionEffectPlan) -> Array[StringName]:
|
||||
var kinds: Array[StringName] = []
|
||||
for operation in plan.get_operations():
|
||||
kinds.append(StringName(operation["operation_kind"]))
|
||||
return kinds
|
||||
|
||||
|
||||
func _has_error(errors: Array[String], fragment: String) -> bool:
|
||||
for error in errors:
|
||||
if error.contains(fragment):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvf4ahtde5dc0
|
||||
Reference in New Issue
Block a user