From dd0fc7542ce2bf79c45234269a94b8a9acbef369 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 20:21:14 +0200 Subject: [PATCH] feat: define authoritative action effects --- simulation/commands/ActionCommand.gd | 33 +++++++++-- simulation/commands/ActionCommandService.gd | 6 +- simulation/commands/ActionEffect.gd | 62 +++++++++++++++++++++ simulation/commands/ActionEffect.gd.uid | 1 + simulation/commands/InteractionService.gd | 6 +- simulation/definitions/ActionDefinition.gd | 36 ++++++++++++ simulation/definitions/ContentCatalog.gd | 18 +++++- tests/content_catalog_test.gd | 12 ++++ 8 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 simulation/commands/ActionEffect.gd create mode 100644 simulation/commands/ActionEffect.gd.uid diff --git a/simulation/commands/ActionCommand.gd b/simulation/commands/ActionCommand.gd index dff2406..99f0d63 100644 --- a/simulation/commands/ActionCommand.gd +++ b/simulation/commands/ActionCommand.gd @@ -2,33 +2,38 @@ class_name ActionCommand extends RefCounted var _command_id: StringName -var _actor_id: StringName +var _actor: WorldEntityRef var _action_id: StringName var _target: WorldTargetHandle var _parameters: Dictionary +var _expected_state_revision: int func _init( command_id: StringName = &"", - actor_id: StringName = &"", + actor: Variant = null, action_id: StringName = &"", target: WorldTargetHandle = null, - parameters: Dictionary = {} + parameters: Dictionary = {}, + expected_state_revision: int = -1 ) -> void: _command_id = command_id - _actor_id = actor_id + _actor = _normalize_actor(actor) _action_id = action_id _target = target _parameters = parameters.duplicate(true) + _expected_state_revision = expected_state_revision func is_valid() -> bool: return ( not _command_id.is_empty() - and not _actor_id.is_empty() + and _actor != null + and _actor.is_valid() and not _action_id.is_empty() and _target != null and _target.is_well_formed() + and _expected_state_revision >= -1 and not WorldTargetCapability._contains_object(_parameters) ) @@ -38,7 +43,11 @@ func get_command_id() -> StringName: func get_actor_id() -> StringName: - return _actor_id + return _actor.get_entity_id() if _actor != null else &"" + + +func get_actor() -> WorldEntityRef: + return _actor.duplicate_ref() if _actor != null else null func get_action_id() -> StringName: @@ -51,3 +60,15 @@ func get_target() -> WorldTargetHandle: func get_parameters() -> Dictionary: return _parameters.duplicate(true) + + +func get_expected_state_revision() -> int: + return _expected_state_revision + + +static func _normalize_actor(actor: Variant) -> WorldEntityRef: + if actor is WorldEntityRef: + return (actor as WorldEntityRef).duplicate_ref() + if actor is StringName or actor is String: + return WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(actor)) + return null diff --git a/simulation/commands/ActionCommandService.gd b/simulation/commands/ActionCommandService.gd index 79abc54..b86a162 100644 --- a/simulation/commands/ActionCommandService.gd +++ b/simulation/commands/ActionCommandService.gd @@ -6,5 +6,9 @@ signal command_resolved(result: ActionResult) # Implementations must resolve the transient target handle and revalidate every # precondition. An earlier ActionOffer never grants authority by itself. -func submit_command(_command: ActionCommand) -> ActionResult: +func try_execute(_command: ActionCommand) -> ActionResult: return null + + +func submit_command(command: ActionCommand) -> ActionResult: + return try_execute(command) diff --git a/simulation/commands/ActionEffect.gd b/simulation/commands/ActionEffect.gd new file mode 100644 index 0000000..c3a0d6b --- /dev/null +++ b/simulation/commands/ActionEffect.gd @@ -0,0 +1,62 @@ +class_name ActionEffect +extends Resource + +const KIND_METRIC_DELTA := &"metric_delta" +const KIND_NEED_DELTA := &"need_delta" +const KIND_INVENTORY_TRANSFER := &"inventory_transfer" +const KIND_DAMAGE := &"damage" +const KIND_HEALING := &"healing" +const KIND_RELATIONSHIP_DELTA := &"relationship_delta" +const KIND_SCHEDULE_ACTION := &"schedule_action" +const KIND_SITE_CONDITION := &"site_condition" +const VALID_KINDS := [ + KIND_METRIC_DELTA, + KIND_NEED_DELTA, + KIND_INVENTORY_TRANSFER, + KIND_DAMAGE, + KIND_HEALING, + KIND_RELATIONSHIP_DELTA, + KIND_SCHEDULE_ACTION, + KIND_SITE_CONDITION, +] + +@export var effect_id: StringName +@export var effect_kind: StringName +@export var subject_key: StringName +@export var value := 0.0 +@export var item_id: StringName +@export var amount := 0.0 +@export var source_role: StringName +@export var destination_role: StringName +@export var scheduled_action_id: StringName +@export var parameters: Dictionary = {} + + +func validate() -> Array[String]: + var errors: Array[String] = [] + if effect_id.is_empty(): + errors.append("effect_id is empty") + if effect_kind not in VALID_KINDS: + errors.append("effect_kind '%s' is invalid for '%s'" % [effect_kind, effect_id]) + if not is_finite(value) or not is_finite(amount): + errors.append("numeric values must be finite for '%s'" % effect_id) + match effect_kind: + KIND_METRIC_DELTA, KIND_NEED_DELTA, KIND_RELATIONSHIP_DELTA, KIND_SITE_CONDITION: + if subject_key.is_empty(): + errors.append("subject_key is empty for '%s'" % effect_id) + KIND_INVENTORY_TRANSFER: + if item_id.is_empty() or amount <= 0.0: + errors.append( + "inventory transfer '%s' needs an item and positive amount" % effect_id + ) + if source_role.is_empty() or destination_role.is_empty(): + errors.append("inventory transfer '%s' needs source/destination roles" % effect_id) + KIND_DAMAGE, KIND_HEALING: + if amount <= 0.0: + errors.append("effect '%s' needs a positive amount" % effect_id) + KIND_SCHEDULE_ACTION: + if scheduled_action_id.is_empty(): + errors.append("scheduled action is empty for '%s'" % effect_id) + if WorldTargetCapability._contains_object(parameters): + errors.append("parameters contain a runtime object for '%s'" % effect_id) + return errors diff --git a/simulation/commands/ActionEffect.gd.uid b/simulation/commands/ActionEffect.gd.uid new file mode 100644 index 0000000..9adc8f0 --- /dev/null +++ b/simulation/commands/ActionEffect.gd.uid @@ -0,0 +1 @@ +uid://c7wc7p30s3fsw diff --git a/simulation/commands/InteractionService.gd b/simulation/commands/InteractionService.gd index 9d5397b..eec0e83 100644 --- a/simulation/commands/InteractionService.gd +++ b/simulation/commands/InteractionService.gd @@ -4,5 +4,9 @@ extends RefCounted # Offers are advisory presentation values. Implementations must not mutate # authoritative state while answering this query. -func get_action_offers(_actor_id: StringName, _target: WorldTargetHandle) -> Array[ActionOffer]: +func get_offers(_actor: WorldEntityRef, _target: WorldTargetHandle) -> Array[ActionOffer]: return [] + + +func get_action_offers(actor_id: StringName, target: WorldTargetHandle) -> Array[ActionOffer]: + return get_offers(WorldEntityRef.create(SimulationIds.ENTITY_PERSON, actor_id), target) diff --git a/simulation/definitions/ActionDefinition.gd b/simulation/definitions/ActionDefinition.gd index c5eb962..d3a16c9 100644 --- a/simulation/definitions/ActionDefinition.gd +++ b/simulation/definitions/ActionDefinition.gd @@ -1,6 +1,8 @@ class_name ActionDefinition extends Resource +const ActionEffectScript := preload("res://simulation/commands/ActionEffect.gd") + @export var action_id: StringName @export var display_name: String @export_range(0.0, 1000.0, 0.1) var default_duration := 1.0 @@ -9,6 +11,14 @@ extends Resource @export var resource_action_id: StringName @export var completion_cost_resource_id: StringName @export_range(0.0, 1000.0, 0.1) var completion_cost_amount := 0.0 +@export var actor_capability_tags: Array[StringName] = [] +@export var target_capability_tags: Array[StringName] = [] +@export var selection_policy_id: StringName +@export var target_policy_id: StringName +@export var completion_handler_id: StringName +@export var effects: Array[Resource] = [] +@export var event_type_id: StringName +@export var presentation_cue_id: StringName func validate() -> Array[String]: @@ -35,8 +45,34 @@ func validate() -> Array[String]: errors.append("completion cost resource is required for '%s'" % action_id) if not completion_cost_resource_id.is_empty() and completion_cost_amount <= 0.0: errors.append("completion cost amount must be positive for '%s'" % action_id) + _validate_unique_tags(actor_capability_tags, "actor capability", errors) + _validate_unique_tags(target_capability_tags, "target capability", errors) + var effect_ids := {} + for effect in effects: + if effect == null or effect.get_script() != ActionEffectScript: + errors.append("effects contains a null definition for '%s'" % action_id) + continue + for error in effect.call("validate"): + errors.append("ActionEffect: " + error) + var effect_id: StringName = effect.get("effect_id") + if effect_ids.has(effect_id): + errors.append("duplicate effect_id '%s' for '%s'" % [effect_id, action_id]) + effect_ids[effect_id] = true return errors func has_completion_cost() -> bool: return not completion_cost_resource_id.is_empty() and completion_cost_amount > 0.0 + + +static func _validate_unique_tags( + tags: Array[StringName], label: String, errors: Array[String] +) -> void: + var seen := {} + for tag in tags: + if tag.is_empty(): + errors.append("%s is empty" % label) + elif seen.has(tag): + errors.append("duplicate %s '%s'" % [label, tag]) + else: + seen[tag] = true diff --git a/simulation/definitions/ContentCatalog.gd b/simulation/definitions/ContentCatalog.gd index ceadfa1..d90eaf9 100644 --- a/simulation/definitions/ContentCatalog.gd +++ b/simulation/definitions/ContentCatalog.gd @@ -92,7 +92,9 @@ func rebuild( ) ) - _validate_action_references(actions, actions_by_id, professions_by_id, items_by_id, errors) + _validate_action_references( + actions, actions_by_id, professions_by_id, items_by_id, supported_handlers, errors + ) _validate_enemy_references(enemies, items_by_id, errors) errors.sort() _errors = errors @@ -249,9 +251,23 @@ static func _validate_action_references( actions_by_id: Dictionary, professions_by_id: Dictionary, items_by_id: Dictionary, + supported_handlers: Dictionary, errors: Array[String] ) -> void: for definition in actions: + for handler_field in [ + ["selection policy", definition.selection_policy_id], + ["target policy", definition.target_policy_id], + ["completion handler", definition.completion_handler_id], + ]: + var handler_id: StringName = handler_field[1] + if not handler_id.is_empty() and not supported_handlers.has(handler_id): + errors.append( + ( + "Action '%s' references unknown %s '%s'" + % [definition.action_id, handler_field[0], handler_id] + ) + ) if ( not definition.preferred_profession_id.is_empty() and not professions_by_id.has(definition.preferred_profession_id) diff --git a/tests/content_catalog_test.gd b/tests/content_catalog_test.gd index 243720e..1b1afa0 100644 --- a/tests/content_catalog_test.gd +++ b/tests/content_catalog_test.gd @@ -141,6 +141,13 @@ func _test_unknown_references_categories_and_handlers() -> void: var missing_cost_item := _action(&"missing_cost") missing_cost_item.completion_cost_resource_id = &"unknown_cost_item" missing_cost_item.completion_cost_amount = 1.0 + missing_cost_item.selection_policy_id = &"unknown_selection" + missing_cost_item.target_policy_id = &"unknown_targeting" + missing_cost_item.completion_handler_id = &"unknown_completion" + var invalid_effect := ActionEffect.new() + invalid_effect.effect_id = &"broken_transfer" + invalid_effect.effect_kind = ActionEffect.KIND_INVENTORY_TRANSFER + missing_cost_item.effects = [invalid_effect] var weapon_item := _weapon_item(&"test_weapon", "Test weapon") var weapon_cost := _action(&"weapon_cost") weapon_cost.completion_cost_resource_id = weapon_item.item_id @@ -161,6 +168,11 @@ func _test_unknown_references_categories_and_handlers() -> void: "unknown profession 'unknown_profession'", "unknown resource action 'unknown_resource_action'", "unknown completion cost item 'unknown_cost_item'", + "unknown selection policy 'unknown_selection'", + "unknown target policy 'unknown_targeting'", + "unknown completion handler 'unknown_completion'", + "needs an item and positive amount", + "needs source/destination roles", "non-resource completion cost item 'test_weapon'", "unknown weapon 'unknown_weapon'", "non-weapon item 'ordinary_resource'",