From af77bbbe65206bdccb91f4d5b994667d43122b1c Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 22:29:46 +0200 Subject: [PATCH] feat: plan typed action effects --- .../commands/ActionEffectCommitResult.gd | 102 ++++++ .../commands/ActionEffectCommitResult.gd.uid | 1 + simulation/commands/ActionEffectPlan.gd | 277 +++++++++++++++ simulation/commands/ActionEffectPlan.gd.uid | 1 + simulation/commands/ActionEffectPlanner.gd | 176 +++++++++ .../commands/ActionEffectPlanner.gd.uid | 1 + .../TransactionalActionEffectCommitter.gd | 56 +++ .../TransactionalActionEffectCommitter.gd.uid | 1 + tests/unit/test_action_effect_planning.gd | 334 ++++++++++++++++++ tests/unit/test_action_effect_planning.gd.uid | 1 + 10 files changed, 950 insertions(+) create mode 100644 simulation/commands/ActionEffectCommitResult.gd create mode 100644 simulation/commands/ActionEffectCommitResult.gd.uid create mode 100644 simulation/commands/ActionEffectPlan.gd create mode 100644 simulation/commands/ActionEffectPlan.gd.uid create mode 100644 simulation/commands/ActionEffectPlanner.gd create mode 100644 simulation/commands/ActionEffectPlanner.gd.uid create mode 100644 simulation/commands/TransactionalActionEffectCommitter.gd create mode 100644 simulation/commands/TransactionalActionEffectCommitter.gd.uid create mode 100644 tests/unit/test_action_effect_planning.gd create mode 100644 tests/unit/test_action_effect_planning.gd.uid diff --git a/simulation/commands/ActionEffectCommitResult.gd b/simulation/commands/ActionEffectCommitResult.gd new file mode 100644 index 0000000..5a548c0 --- /dev/null +++ b/simulation/commands/ActionEffectCommitResult.gd @@ -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() diff --git a/simulation/commands/ActionEffectCommitResult.gd.uid b/simulation/commands/ActionEffectCommitResult.gd.uid new file mode 100644 index 0000000..02ccfe8 --- /dev/null +++ b/simulation/commands/ActionEffectCommitResult.gd.uid @@ -0,0 +1 @@ +uid://df1w0x8o3k3i4 diff --git a/simulation/commands/ActionEffectPlan.gd b/simulation/commands/ActionEffectPlan.gd new file mode 100644 index 0000000..2d4d8e8 --- /dev/null +++ b/simulation/commands/ActionEffectPlan.gd @@ -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 diff --git a/simulation/commands/ActionEffectPlan.gd.uid b/simulation/commands/ActionEffectPlan.gd.uid new file mode 100644 index 0000000..c4fb66b --- /dev/null +++ b/simulation/commands/ActionEffectPlan.gd.uid @@ -0,0 +1 @@ +uid://b7a6qjmsnhq1c diff --git a/simulation/commands/ActionEffectPlanner.gd b/simulation/commands/ActionEffectPlanner.gd new file mode 100644 index 0000000..a941de1 --- /dev/null +++ b/simulation/commands/ActionEffectPlanner.gd @@ -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 diff --git a/simulation/commands/ActionEffectPlanner.gd.uid b/simulation/commands/ActionEffectPlanner.gd.uid new file mode 100644 index 0000000..910b108 --- /dev/null +++ b/simulation/commands/ActionEffectPlanner.gd.uid @@ -0,0 +1 @@ +uid://c8pxhf0fed0m1 diff --git a/simulation/commands/TransactionalActionEffectCommitter.gd b/simulation/commands/TransactionalActionEffectCommitter.gd new file mode 100644 index 0000000..43958f5 --- /dev/null +++ b/simulation/commands/TransactionalActionEffectCommitter.gd @@ -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"], + ) + ) diff --git a/simulation/commands/TransactionalActionEffectCommitter.gd.uid b/simulation/commands/TransactionalActionEffectCommitter.gd.uid new file mode 100644 index 0000000..95ce024 --- /dev/null +++ b/simulation/commands/TransactionalActionEffectCommitter.gd.uid @@ -0,0 +1 @@ +uid://bqt868pi0usxo diff --git a/tests/unit/test_action_effect_planning.gd b/tests/unit/test_action_effect_planning.gd new file mode 100644 index 0000000..8de1aff --- /dev/null +++ b/tests/unit/test_action_effect_planning.gd @@ -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 diff --git a/tests/unit/test_action_effect_planning.gd.uid b/tests/unit/test_action_effect_planning.gd.uid new file mode 100644 index 0000000..ac2caf6 --- /dev/null +++ b/tests/unit/test_action_effect_planning.gd.uid @@ -0,0 +1 @@ +uid://cvf4ahtde5dc0