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
+334
View File
@@ -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