Files
gamedev-the-steward/simulation/commands/ActionEffectPlan.gd
T
2026-08-12 22:29:46 +02:00

278 lines
9.6 KiB
GDScript

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