diff --git a/simulation/commands/ActionCommand.gd b/simulation/commands/ActionCommand.gd new file mode 100644 index 0000000..dff2406 --- /dev/null +++ b/simulation/commands/ActionCommand.gd @@ -0,0 +1,53 @@ +class_name ActionCommand +extends RefCounted + +var _command_id: StringName +var _actor_id: StringName +var _action_id: StringName +var _target: WorldTargetHandle +var _parameters: Dictionary + + +func _init( + command_id: StringName = &"", + actor_id: StringName = &"", + action_id: StringName = &"", + target: WorldTargetHandle = null, + parameters: Dictionary = {} +) -> void: + _command_id = command_id + _actor_id = actor_id + _action_id = action_id + _target = target + _parameters = parameters.duplicate(true) + + +func is_valid() -> bool: + return ( + not _command_id.is_empty() + and not _actor_id.is_empty() + and not _action_id.is_empty() + and _target != null + and _target.is_well_formed() + and not WorldTargetCapability._contains_object(_parameters) + ) + + +func get_command_id() -> StringName: + return _command_id + + +func get_actor_id() -> StringName: + return _actor_id + + +func get_action_id() -> StringName: + return _action_id + + +func get_target() -> WorldTargetHandle: + return _target + + +func get_parameters() -> Dictionary: + return _parameters.duplicate(true) diff --git a/simulation/commands/ActionCommand.gd.uid b/simulation/commands/ActionCommand.gd.uid new file mode 100644 index 0000000..030b442 --- /dev/null +++ b/simulation/commands/ActionCommand.gd.uid @@ -0,0 +1 @@ +uid://doxwel0w8eqx5 diff --git a/simulation/commands/ActionCommandService.gd b/simulation/commands/ActionCommandService.gd new file mode 100644 index 0000000..79abc54 --- /dev/null +++ b/simulation/commands/ActionCommandService.gd @@ -0,0 +1,10 @@ +class_name ActionCommandService +extends RefCounted + +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: + return null diff --git a/simulation/commands/ActionCommandService.gd.uid b/simulation/commands/ActionCommandService.gd.uid new file mode 100644 index 0000000..f88f93a --- /dev/null +++ b/simulation/commands/ActionCommandService.gd.uid @@ -0,0 +1 @@ +uid://dfbj1brsbira5 diff --git a/simulation/commands/ActionOffer.gd b/simulation/commands/ActionOffer.gd new file mode 100644 index 0000000..05b883e --- /dev/null +++ b/simulation/commands/ActionOffer.gd @@ -0,0 +1,74 @@ +class_name ActionOffer +extends RefCounted + +var _offer_id: StringName +var _action_id: StringName +var _target: WorldTargetHandle +var _display_name: String +var _enabled: bool +var _rejection_reason: String +var _parameters: Dictionary +var _priority: float + + +func _init( + offer_id: StringName = &"", + action_id: StringName = &"", + target: WorldTargetHandle = null, + display_name: String = "", + enabled: bool = true, + rejection_reason: String = "", + parameters: Dictionary = {}, + priority: float = 0.0 +) -> void: + _offer_id = offer_id + _action_id = action_id + _target = target + _display_name = display_name + _enabled = enabled + _rejection_reason = rejection_reason + _parameters = parameters.duplicate(true) + _priority = priority + + +func is_valid() -> bool: + return ( + not _offer_id.is_empty() + and not _action_id.is_empty() + and _target != null + and _target.is_well_formed() + and is_finite(_priority) + and not WorldTargetCapability._contains_object(_parameters) + ) + + +func get_offer_id() -> StringName: + return _offer_id + + +func get_action_id() -> StringName: + return _action_id + + +func get_target() -> WorldTargetHandle: + return _target + + +func get_display_name() -> String: + return _display_name + + +func is_enabled() -> bool: + return _enabled + + +func get_rejection_reason() -> String: + return _rejection_reason + + +func get_parameters() -> Dictionary: + return _parameters.duplicate(true) + + +func get_priority() -> float: + return _priority diff --git a/simulation/commands/ActionOffer.gd.uid b/simulation/commands/ActionOffer.gd.uid new file mode 100644 index 0000000..2b34d73 --- /dev/null +++ b/simulation/commands/ActionOffer.gd.uid @@ -0,0 +1 @@ +uid://c34gchiomlnpy diff --git a/simulation/commands/ActionResult.gd b/simulation/commands/ActionResult.gd new file mode 100644 index 0000000..f4e2548 --- /dev/null +++ b/simulation/commands/ActionResult.gd @@ -0,0 +1,88 @@ +class_name ActionResult +extends RefCounted + +const STATUS_ACCEPTED := &"accepted" +const STATUS_REJECTED := &"rejected" +const STATUS_COMPLETED := &"completed" +const STATUS_FAILED := &"failed" +const VALID_STATUSES := [STATUS_ACCEPTED, STATUS_REJECTED, STATUS_COMPLETED, STATUS_FAILED] + +var _command_id: StringName +var _status: StringName +var _reason_code: StringName +var _message: String +var _payload: Dictionary + + +func _init( + command_id: StringName = &"", + status: StringName = &"", + reason_code: StringName = &"", + message: String = "", + payload: Dictionary = {} +) -> void: + _command_id = command_id + _status = status + _reason_code = reason_code + _message = message + _payload = payload.duplicate(true) + + +func is_valid() -> bool: + return ( + not _command_id.is_empty() + and _status in VALID_STATUSES + and not WorldTargetCapability._contains_object(_payload) + ) + + +func is_accepted() -> bool: + return _status in [STATUS_ACCEPTED, STATUS_COMPLETED] + + +func is_terminal() -> bool: + return _status in [STATUS_REJECTED, STATUS_COMPLETED, STATUS_FAILED] + + +func did_succeed() -> bool: + return _status == STATUS_COMPLETED + + +func get_command_id() -> StringName: + return _command_id + + +func get_status() -> StringName: + return _status + + +func get_reason_code() -> StringName: + return _reason_code + + +func get_message() -> String: + return _message + + +func get_payload() -> Dictionary: + return _payload.duplicate(true) + + +static func accepted(command_id: StringName, payload: Dictionary = {}) -> ActionResult: + return ActionResult.new(command_id, STATUS_ACCEPTED, &"", "", payload) + + +static func rejected( + command_id: StringName, reason_code: StringName, message: String = "" +) -> ActionResult: + return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message) + + +static func completed(command_id: StringName, payload: Dictionary = {}) -> ActionResult: + return ActionResult.new(command_id, STATUS_COMPLETED, &"", "", payload) + + +static func failed( + command_id: StringName, reason_code: StringName, message: String = "" +) -> ActionResult: + return ActionResult.new(command_id, STATUS_FAILED, reason_code, message) diff --git a/simulation/commands/ActionResult.gd.uid b/simulation/commands/ActionResult.gd.uid new file mode 100644 index 0000000..bdf1956 --- /dev/null +++ b/simulation/commands/ActionResult.gd.uid @@ -0,0 +1 @@ +uid://bs85tiv8cs3gi diff --git a/simulation/commands/InteractionService.gd b/simulation/commands/InteractionService.gd new file mode 100644 index 0000000..9d5397b --- /dev/null +++ b/simulation/commands/InteractionService.gd @@ -0,0 +1,8 @@ +class_name InteractionService +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]: + return [] diff --git a/simulation/commands/InteractionService.gd.uid b/simulation/commands/InteractionService.gd.uid new file mode 100644 index 0000000..d17a737 --- /dev/null +++ b/simulation/commands/InteractionService.gd.uid @@ -0,0 +1 @@ +uid://bgd28t7y4x3mf diff --git a/simulation/definitions/ContentCatalog.gd b/simulation/definitions/ContentCatalog.gd new file mode 100644 index 0000000..ceadfa1 --- /dev/null +++ b/simulation/definitions/ContentCatalog.gd @@ -0,0 +1,340 @@ +class_name ContentCatalog +extends RefCounted + +const CORE_PACK_PATH := "res://simulation/definitions/packs/core.tres" + +var _valid := false +var _errors: Array[String] = [] +var _packs: Array[SimulationContentPack] = [] +var _actions: Array[ActionDefinition] = [] +var _professions: Array[ProfessionDefinition] = [] +var _items: Array[ItemDefinition] = [] +var _enemies: Array[EnemyDefinition] = [] +var _packs_by_id: Dictionary = {} +var _actions_by_id: Dictionary = {} +var _professions_by_id: Dictionary = {} +var _items_by_id: Dictionary = {} +var _enemies_by_id: Dictionary = {} + + +static func create_core(supported_handler_ids: Array[StringName] = []) -> ContentCatalog: + var catalog := ContentCatalog.new() + var pack := load(CORE_PACK_PATH) as SimulationContentPack + if pack == null: + catalog._errors = ["Core content pack failed to load from '%s'" % CORE_PACK_PATH] + return catalog + var packs: Array[SimulationContentPack] = [pack] + catalog.rebuild(packs, supported_handler_ids) + return catalog + + +func rebuild( + content_packs: Array[SimulationContentPack], supported_handler_ids: Array[StringName] = [] +) -> Array[String]: + _clear_published_content() + var errors: Array[String] = [] + var pack_list := content_packs.duplicate() + pack_list.sort_custom(_sort_packs) + var supported_handlers := {} + for handler_id in supported_handler_ids: + if handler_id.is_empty(): + errors.append("Supported handler ID is empty") + elif supported_handlers.has(handler_id): + errors.append("Duplicate supported handler '%s'" % handler_id) + else: + supported_handlers[handler_id] = true + + var packs_by_id := {} + var actions_by_id := {} + var professions_by_id := {} + var items_by_id := {} + var enemies_by_id := {} + var actions: Array[ActionDefinition] = [] + var professions: Array[ProfessionDefinition] = [] + var items: Array[ItemDefinition] = [] + var enemies: Array[EnemyDefinition] = [] + + for pack in pack_list: + if pack == null: + errors.append("Content pack list contains a null pack") + continue + for error in pack.validate(): + errors.append("Pack '%s': %s" % [pack.pack_id, error]) + if packs_by_id.has(pack.pack_id): + errors.append("Duplicate pack_id '%s'" % pack.pack_id) + else: + packs_by_id[pack.pack_id] = pack + _collect_actions(pack, actions, actions_by_id, errors) + _collect_professions(pack, professions, professions_by_id, errors) + _collect_items(pack, items, items_by_id, errors) + _collect_enemies(pack, enemies, enemies_by_id, errors) + + for pack in pack_list: + if pack == null: + continue + for required_pack_id in pack.required_pack_ids: + if not required_pack_id.is_empty() and not packs_by_id.has(required_pack_id): + errors.append( + ( + "Pack '%s' references unknown required pack '%s'" + % [pack.pack_id, required_pack_id] + ) + ) + for required_handler_id in pack.required_handler_ids: + if ( + not required_handler_id.is_empty() + and not supported_handlers.has(required_handler_id) + ): + errors.append( + ( + "Pack '%s' references unknown required handler '%s'" + % [pack.pack_id, required_handler_id] + ) + ) + + _validate_action_references(actions, actions_by_id, professions_by_id, items_by_id, errors) + _validate_enemy_references(enemies, items_by_id, errors) + errors.sort() + _errors = errors + if not errors.is_empty(): + return get_errors() + + actions.sort_custom(_sort_actions) + professions.sort_custom(_sort_professions) + items.sort_custom(_sort_items) + enemies.sort_custom(_sort_enemies) + _packs = pack_list + _actions = actions + _professions = professions + _items = items + _enemies = enemies + _packs_by_id = packs_by_id + _actions_by_id = actions_by_id + _professions_by_id = professions_by_id + _items_by_id = items_by_id + _enemies_by_id = enemies_by_id + _valid = true + return [] + + +func is_valid() -> bool: + return _valid + + +func get_errors() -> Array[String]: + return _errors.duplicate() + + +func get_packs() -> Array[SimulationContentPack]: + return _packs.duplicate() + + +func get_actions() -> Array[ActionDefinition]: + return _actions.duplicate() + + +func get_professions() -> Array[ProfessionDefinition]: + return _professions.duplicate() + + +func get_items() -> Array[ItemDefinition]: + return _items.duplicate() + + +func get_enemies() -> Array[EnemyDefinition]: + return _enemies.duplicate() + + +func get_pack(pack_id: StringName) -> SimulationContentPack: + return _packs_by_id.get(pack_id) as SimulationContentPack + + +func get_action(action_id: StringName) -> ActionDefinition: + return _actions_by_id.get(action_id) as ActionDefinition + + +func get_profession(profession_id: StringName) -> ProfessionDefinition: + return _professions_by_id.get(profession_id) as ProfessionDefinition + + +func get_item(item_id: StringName) -> ItemDefinition: + return _items_by_id.get(item_id) as ItemDefinition + + +func get_enemy(enemy_id: StringName) -> EnemyDefinition: + return _enemies_by_id.get(enemy_id) as EnemyDefinition + + +func _clear_published_content() -> void: + _valid = false + _errors.clear() + _packs.clear() + _actions.clear() + _professions.clear() + _items.clear() + _enemies.clear() + _packs_by_id.clear() + _actions_by_id.clear() + _professions_by_id.clear() + _items_by_id.clear() + _enemies_by_id.clear() + + +static func _collect_actions( + pack: SimulationContentPack, + definitions: Array[ActionDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.actions: + if definition == null: + continue + if definitions_by_id.has(definition.action_id): + errors.append("Duplicate action_id '%s'" % definition.action_id) + continue + definitions_by_id[definition.action_id] = definition + definitions.append(definition) + + +static func _collect_professions( + pack: SimulationContentPack, + definitions: Array[ProfessionDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.professions: + if definition == null: + continue + if definitions_by_id.has(definition.profession_id): + errors.append("Duplicate profession_id '%s'" % definition.profession_id) + continue + definitions_by_id[definition.profession_id] = definition + definitions.append(definition) + + +static func _collect_items( + pack: SimulationContentPack, + definitions: Array[ItemDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.items: + if definition == null: + continue + if definitions_by_id.has(definition.item_id): + errors.append("Duplicate item_id '%s'" % definition.item_id) + continue + definitions_by_id[definition.item_id] = definition + definitions.append(definition) + + +static func _collect_enemies( + pack: SimulationContentPack, + definitions: Array[EnemyDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.enemies: + if definition == null: + continue + if definitions_by_id.has(definition.enemy_id): + errors.append("Duplicate enemy_id '%s'" % definition.enemy_id) + continue + definitions_by_id[definition.enemy_id] = definition + definitions.append(definition) + + +static func _validate_action_references( + actions: Array[ActionDefinition], + actions_by_id: Dictionary, + professions_by_id: Dictionary, + items_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in actions: + if ( + not definition.preferred_profession_id.is_empty() + and not professions_by_id.has(definition.preferred_profession_id) + ): + errors.append( + ( + "Action '%s' references unknown profession '%s'" + % [definition.action_id, definition.preferred_profession_id] + ) + ) + if ( + definition.target_type == SimulationIds.TARGET_RESOURCE + and not actions_by_id.has(definition.resource_action_id) + ): + errors.append( + ( + "Action '%s' references unknown resource action '%s'" + % [definition.action_id, definition.resource_action_id] + ) + ) + if ( + not definition.completion_cost_resource_id.is_empty() + and not items_by_id.has(definition.completion_cost_resource_id) + ): + errors.append( + ( + "Action '%s' references unknown completion cost item '%s'" + % [definition.action_id, definition.completion_cost_resource_id] + ) + ) + elif not definition.completion_cost_resource_id.is_empty(): + var cost_item := ( + items_by_id.get(definition.completion_cost_resource_id) as ItemDefinition + ) + if cost_item != null and not cost_item.is_resource(): + errors.append( + ( + "Action '%s' references non-resource completion cost item '%s'" + % [definition.action_id, definition.completion_cost_resource_id] + ) + ) + + +static func _validate_enemy_references( + enemies: Array[EnemyDefinition], items_by_id: Dictionary, errors: Array[String] +) -> void: + for definition in enemies: + var weapon := items_by_id.get(definition.weapon_id) as ItemDefinition + if weapon == null: + errors.append( + ( + "Enemy '%s' references unknown weapon '%s'" + % [definition.enemy_id, definition.weapon_id] + ) + ) + elif not weapon.is_weapon(): + errors.append( + ( + "Enemy '%s' references non-weapon item '%s'" + % [definition.enemy_id, definition.weapon_id] + ) + ) + + +static func _sort_packs(first: SimulationContentPack, second: SimulationContentPack) -> bool: + if first == null: + return second != null + if second == null: + return false + return String(first.pack_id) < String(second.pack_id) + + +static func _sort_actions(first: ActionDefinition, second: ActionDefinition) -> bool: + return String(first.action_id) < String(second.action_id) + + +static func _sort_professions(first: ProfessionDefinition, second: ProfessionDefinition) -> bool: + return String(first.profession_id) < String(second.profession_id) + + +static func _sort_items(first: ItemDefinition, second: ItemDefinition) -> bool: + return String(first.item_id) < String(second.item_id) + + +static func _sort_enemies(first: EnemyDefinition, second: EnemyDefinition) -> bool: + return String(first.enemy_id) < String(second.enemy_id) diff --git a/simulation/definitions/ContentCatalog.gd.uid b/simulation/definitions/ContentCatalog.gd.uid new file mode 100644 index 0000000..09eb883 --- /dev/null +++ b/simulation/definitions/ContentCatalog.gd.uid @@ -0,0 +1 @@ +uid://csvigy66784n diff --git a/simulation/definitions/ItemDefinition.gd b/simulation/definitions/ItemDefinition.gd index b074839..8f4d0a9 100644 --- a/simulation/definitions/ItemDefinition.gd +++ b/simulation/definitions/ItemDefinition.gd @@ -1,13 +1,17 @@ class_name ItemDefinition extends Resource +const CATEGORY_RESOURCE := &"resource" +const CATEGORY_WEAPON := SimulationIds.ITEM_CATEGORY_WEAPON +const VALID_CATEGORIES := [CATEGORY_RESOURCE, CATEGORY_WEAPON] + @export var item_id: StringName @export var display_name: String -@export var category: StringName = SimulationIds.ITEM_CATEGORY_WEAPON -@export var equip_slot: StringName = SimulationIds.EQUIP_SLOT_HAND +@export var category: StringName = CATEGORY_RESOURCE +@export var equip_slot: StringName @export_range(0.0, 1000.0, 0.1) var damage := 0.0 -@export_range(0.0, 50.0, 0.1) var reach := 1.5 -@export_range(0.0, 5.0, 0.05) var attack_cooldown := 0.6 +@export_range(0.0, 50.0, 0.1) var reach := 0.0 +@export_range(0.0, 5.0, 0.05) var attack_cooldown := 0.0 func validate() -> Array[String]: @@ -16,10 +20,27 @@ func validate() -> Array[String]: errors.append("item_id is empty") if display_name.is_empty(): errors.append("display_name is empty for '%s'" % item_id) - if damage <= 0.0: - errors.append("damage must be positive for '%s'" % item_id) + if category not in VALID_CATEGORIES: + errors.append("category '%s' is invalid for '%s'" % [category, item_id]) + elif category == CATEGORY_WEAPON: + if equip_slot != SimulationIds.EQUIP_SLOT_HAND: + errors.append( + "weapon '%s' must equip in '%s'" % [item_id, SimulationIds.EQUIP_SLOT_HAND] + ) + if not is_finite(damage) or damage <= 0.0: + errors.append("damage must be positive for '%s'" % item_id) + if not is_finite(reach) or reach <= 0.0: + errors.append("reach must be positive for '%s'" % item_id) + if not is_finite(attack_cooldown) or attack_cooldown <= 0.0: + errors.append("attack_cooldown must be positive for '%s'" % item_id) + elif not equip_slot.is_empty(): + errors.append("resource item '%s' cannot declare equip_slot '%s'" % [item_id, equip_slot]) return errors func is_weapon() -> bool: - return category == SimulationIds.ITEM_CATEGORY_WEAPON + return category == CATEGORY_WEAPON + + +func is_resource() -> bool: + return category == CATEGORY_RESOURCE diff --git a/simulation/definitions/SimulationContentPack.gd b/simulation/definitions/SimulationContentPack.gd new file mode 100644 index 0000000..4ab54e5 --- /dev/null +++ b/simulation/definitions/SimulationContentPack.gd @@ -0,0 +1,78 @@ +class_name SimulationContentPack +extends Resource + +@export var pack_id: StringName +@export var display_name: String +@export var required_pack_ids: Array[StringName] = [] +@export var required_handler_ids: Array[StringName] = [] +@export var actions: Array[ActionDefinition] = [] +@export var professions: Array[ProfessionDefinition] = [] +@export var items: Array[ItemDefinition] = [] +@export var enemies: Array[EnemyDefinition] = [] + + +func validate() -> Array[String]: + var errors: Array[String] = [] + if pack_id.is_empty(): + errors.append("pack_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for pack '%s'" % pack_id) + _validate_dependency_ids(required_pack_ids, "required pack", errors) + _validate_dependency_ids(required_handler_ids, "required handler", errors) + if pack_id in required_pack_ids: + errors.append("pack '%s' cannot require itself" % pack_id) + _validate_actions(errors) + _validate_professions(errors) + _validate_items(errors) + _validate_enemies(errors) + return errors + + +func _validate_actions(errors: Array[String]) -> void: + for definition in actions: + if definition == null: + errors.append("actions contains a null definition") + continue + for error in definition.validate(): + errors.append("ActionDefinition: " + error) + + +func _validate_professions(errors: Array[String]) -> void: + for definition in professions: + if definition == null: + errors.append("professions contains a null definition") + continue + for error in definition.validate(): + errors.append("ProfessionDefinition: " + error) + + +func _validate_items(errors: Array[String]) -> void: + for definition in items: + if definition == null: + errors.append("items contains a null definition") + continue + for error in definition.validate(): + errors.append("ItemDefinition: " + error) + + +func _validate_enemies(errors: Array[String]) -> void: + for definition in enemies: + if definition == null: + errors.append("enemies contains a null definition") + continue + for error in definition.validate(): + errors.append("EnemyDefinition: " + error) + + +static func _validate_dependency_ids( + ids: Array[StringName], label: String, errors: Array[String] +) -> void: + var seen := {} + for dependency_id in ids: + if dependency_id.is_empty(): + errors.append("%s ID is empty" % label) + continue + if seen.has(dependency_id): + errors.append("duplicate %s '%s'" % [label, dependency_id]) + continue + seen[dependency_id] = true diff --git a/simulation/definitions/SimulationContentPack.gd.uid b/simulation/definitions/SimulationContentPack.gd.uid new file mode 100644 index 0000000..4bed9a1 --- /dev/null +++ b/simulation/definitions/SimulationContentPack.gd.uid @@ -0,0 +1 @@ +uid://j65xpgmfakin diff --git a/simulation/definitions/enemies/raider.tres b/simulation/definitions/enemies/raider.tres new file mode 100644 index 0000000..44df45c --- /dev/null +++ b/simulation/definitions/enemies/raider.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/EnemyDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +enemy_id = &"enemy_raider" +display_name = "Raider" +kind = &"raider" +weapon_id = &"item_sword" +health = 60.0 +move_speed = 3.5 +body_color = Color(0.4, 0.13, 0.1, 1) +accent_color = Color(0.55, 0.55, 0.58, 1) diff --git a/simulation/definitions/enemies/wolf.tres b/simulation/definitions/enemies/wolf.tres new file mode 100644 index 0000000..096574d --- /dev/null +++ b/simulation/definitions/enemies/wolf.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/EnemyDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +enemy_id = &"enemy_wolf" +display_name = "Wolf" +kind = &"wolf" +weapon_id = &"item_claw" +health = 40.0 +move_speed = 4.2 +body_color = Color(0.32, 0.32, 0.34, 1) +accent_color = Color(0.95, 0.5, 0.12, 1) +visual_scale = Vector3(0.8, 0.8, 0.8) diff --git a/simulation/definitions/items/claw.tres b/simulation/definitions/items/claw.tres new file mode 100644 index 0000000..9090e75 --- /dev/null +++ b/simulation/definitions/items/claw.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"item_claw" +display_name = "Claw" +category = &"weapon" +equip_slot = &"hand" +damage = 10.0 +reach = 1.2 +attack_cooldown = 0.4 diff --git a/simulation/definitions/items/food.tres b/simulation/definitions/items/food.tres new file mode 100644 index 0000000..e746b1e --- /dev/null +++ b/simulation/definitions/items/food.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"food" +display_name = "Food" +category = &"resource" diff --git a/simulation/definitions/items/sword.tres b/simulation/definitions/items/sword.tres new file mode 100644 index 0000000..67bfba8 --- /dev/null +++ b/simulation/definitions/items/sword.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"item_sword" +display_name = "Sword" +category = &"weapon" +equip_slot = &"hand" +damage = 24.0 +reach = 2.4 +attack_cooldown = 0.55 diff --git a/simulation/definitions/items/wood.tres b/simulation/definitions/items/wood.tres new file mode 100644 index 0000000..e7a2bc6 --- /dev/null +++ b/simulation/definitions/items/wood.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ItemDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +item_id = &"wood" +display_name = "Wood" +category = &"resource" diff --git a/simulation/definitions/packs/core.tres b/simulation/definitions/packs/core.tres new file mode 100644 index 0000000..2e4bb0b --- /dev/null +++ b/simulation/definitions/packs/core.tres @@ -0,0 +1,36 @@ +[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=27 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/deposit_food.tres" id="3_deposit_food"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/deposit_wood.tres" id="4_deposit_wood"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/eat.tres" id="5_eat"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/feed_animal.tres" id="6_feed_animal"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/gather_food.tres" id="7_gather_food"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/gather_wood.tres" id="8_gather_wood"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/patrol.tres" id="9_patrol"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/rest.tres" id="10_rest"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/sleep.tres" id="11_sleep"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/study.tres" id="12_study"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/wander.tres" id="13_wander"] +[ext_resource type="Resource" path="res://simulation/definitions/actions/withdraw_food.tres" id="14_withdraw_food"] +[ext_resource type="Resource" path="res://simulation/definitions/professions/farmer.tres" id="15_farmer"] +[ext_resource type="Resource" path="res://simulation/definitions/professions/guard.tres" id="16_guard"] +[ext_resource type="Resource" path="res://simulation/definitions/professions/scholar.tres" id="17_scholar"] +[ext_resource type="Resource" path="res://simulation/definitions/professions/wanderer.tres" id="18_wanderer"] +[ext_resource type="Resource" path="res://simulation/definitions/professions/woodcutter.tres" id="19_woodcutter"] +[ext_resource type="Resource" path="res://simulation/definitions/items/claw.tres" id="20_claw"] +[ext_resource type="Resource" path="res://simulation/definitions/items/food.tres" id="21_food"] +[ext_resource type="Resource" path="res://simulation/definitions/items/sword.tres" id="22_sword"] +[ext_resource type="Resource" path="res://simulation/definitions/items/wood.tres" id="23_wood"] +[ext_resource type="Resource" path="res://simulation/definitions/enemies/raider.tres" id="24_raider"] +[ext_resource type="Resource" path="res://simulation/definitions/enemies/wolf.tres" id="25_wolf"] + +[resource] +script = ExtResource("1_pack") +pack_id = &"core" +display_name = "The Steward Core" +actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")] +professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")] +items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood")] +enemies = [ExtResource("24_raider"), ExtResource("25_wolf")] diff --git a/simulation/events/WorldEventStore.gd b/simulation/events/WorldEventStore.gd new file mode 100644 index 0000000..e0db121 --- /dev/null +++ b/simulation/events/WorldEventStore.gd @@ -0,0 +1,204 @@ +class_name WorldEventStore +extends RefCounted + +signal event_recorded(event: WorldEventRecord) + +const SCHEMA_VERSION := 1 + +var _events_by_id: Dictionary = {} +var _ordered_event_ids: Array[int] = [] +var _event_type_index: Dictionary = {} +var _participant_index: Dictionary = {} +var _location_index: Dictionary = {} +var _tick_index: Dictionary = {} +var _ordered_ticks: Array[int] = [] + + +static func from_dictionary(store_data: Dictionary) -> WorldEventStore: + var store := WorldEventStore.new() + return store if store.restore_from_dictionary(store_data) else null + + +func size() -> int: + return _events_by_id.size() + + +func is_empty() -> bool: + return _events_by_id.is_empty() + + +func has_event(event_id: int) -> bool: + return _events_by_id.has(event_id) + + +func append(event: WorldEventRecord) -> bool: + return _append(event, true) + + +func append_economic( + economic_event: EconomicEventRecord, world_id: StringName = &"", location_id: StringName = &"" +) -> WorldEventRecord: + var world_event := WorldEventRecord.from_economic_event(economic_event, world_id, location_id) + if world_event == null or not append(world_event): + return null + return get_by_id(world_event.get_event_id()) + + +func get_by_id(event_id: int) -> WorldEventRecord: + return _events_by_id.get(event_id) as WorldEventRecord + + +func get_by_ids(event_ids: Array[int]) -> Array[WorldEventRecord]: + var results: Array[WorldEventRecord] = [] + for event_id in event_ids: + var event := get_by_id(event_id) + if event != null: + results.append(event) + return results + + +func get_all() -> Array[WorldEventRecord]: + return _records_for_ids(_ordered_event_ids) + + +func get_for_type(event_type: StringName) -> Array[WorldEventRecord]: + return _records_for_ids(_event_type_index.get(event_type, [])) + + +func get_for_participant(participant: WorldEntityRef) -> Array[WorldEventRecord]: + if participant == null or not participant.is_valid(): + return [] + return _records_for_ids(_participant_index.get(participant.index_key(), [])) + + +func get_for_location(location: SpatialAddress) -> Array[WorldEventRecord]: + if location == null or not location.is_valid(): + return [] + return _records_for_ids(_location_index.get(location.index_key(), [])) + + +func get_for_tick(tick: int) -> Array[WorldEventRecord]: + return _records_for_ids(_tick_index.get(tick, [])) + + +func get_between_ticks(first_tick: int, last_tick: int) -> Array[WorldEventRecord]: + var results: Array[WorldEventRecord] = [] + if first_tick < 0 or last_tick < first_tick: + return results + for tick in _ordered_ticks: + if tick < first_tick: + continue + if tick > last_tick: + break + results.append_array(_records_for_ids(_tick_index[tick])) + return results + + +func restore(restored_events: Array[WorldEventRecord]) -> bool: + var candidate := WorldEventStore.new() + for event in restored_events: + if not candidate._append(event, false): + return false + _adopt_indexes(candidate) + return true + + +func restore_from_dictionary(store_data: Dictionary) -> bool: + if int(store_data.get("schema_version", -1)) != SCHEMA_VERSION: + return false + var saved_events = store_data.get("events") + if not saved_events is Array: + return false + var restored_events: Array[WorldEventRecord] = [] + for saved_event in saved_events: + if not saved_event is Dictionary: + return false + var event := WorldEventRecord.from_dictionary(saved_event) + if event == null: + return false + restored_events.append(event) + return restore(restored_events) + + +func to_dictionary() -> Dictionary: + var serialized_events: Array[Dictionary] = [] + for event in get_all(): + serialized_events.append(event.to_dictionary()) + return { + "schema_version": SCHEMA_VERSION, + "events": serialized_events, + } + + +func _append(event: WorldEventRecord, emit_signal: bool) -> bool: + if event == null or not event.is_valid() or _events_by_id.has(event.get_event_id()): + return false + var stored_event := WorldEventRecord.from_dictionary(event.to_dictionary()) + if stored_event == null: + return false + var event_id := stored_event.get_event_id() + _events_by_id[event_id] = stored_event + _insert_event_id(_ordered_event_ids, event_id) + _index_event_id(_event_type_index, stored_event.get_event_type(), event_id) + for participant_key in stored_event.get_participant_index_keys(): + _index_event_id(_participant_index, participant_key, event_id) + if stored_event.has_location(): + _index_event_id(_location_index, stored_event.get_location().index_key(), event_id) + _index_event_id(_tick_index, stored_event.get_tick(), event_id) + _insert_tick(stored_event.get_tick()) + if emit_signal: + event_recorded.emit(stored_event) + return true + + +func _index_event_id(index: Dictionary, key: Variant, event_id: int) -> void: + if not index.has(key): + index[key] = [] + var indexed_ids: Array = index[key] + _insert_event_id(indexed_ids, event_id) + + +func _insert_event_id(event_ids: Array, event_id: int) -> void: + var low := 0 + var high := event_ids.size() + while low < high: + var middle := (low + high) / 2 + if _event_precedes(event_id, int(event_ids[middle])): + high = middle + else: + low = middle + 1 + event_ids.insert(low, event_id) + + +func _event_precedes(left_id: int, right_id: int) -> bool: + var left := get_by_id(left_id) + var right := get_by_id(right_id) + if left.get_tick() != right.get_tick(): + return left.get_tick() < right.get_tick() + return left_id < right_id + + +func _insert_tick(tick: int) -> void: + var insert_at := _ordered_ticks.bsearch(tick) + if insert_at < _ordered_ticks.size() and _ordered_ticks[insert_at] == tick: + return + _ordered_ticks.insert(insert_at, tick) + + +func _records_for_ids(event_ids: Array) -> Array[WorldEventRecord]: + var results: Array[WorldEventRecord] = [] + for event_id in event_ids: + var event := get_by_id(int(event_id)) + if event != null: + results.append(event) + return results + + +func _adopt_indexes(source: WorldEventStore) -> void: + _events_by_id = source._events_by_id + _ordered_event_ids = source._ordered_event_ids + _event_type_index = source._event_type_index + _participant_index = source._participant_index + _location_index = source._location_index + _tick_index = source._tick_index + _ordered_ticks = source._ordered_ticks diff --git a/simulation/events/WorldEventStore.gd.uid b/simulation/events/WorldEventStore.gd.uid new file mode 100644 index 0000000..b84fe51 --- /dev/null +++ b/simulation/events/WorldEventStore.gd.uid @@ -0,0 +1 @@ +uid://dss5liteqydyd diff --git a/simulation/state/SpatialAddress.gd b/simulation/state/SpatialAddress.gd new file mode 100644 index 0000000..a624385 --- /dev/null +++ b/simulation/state/SpatialAddress.gd @@ -0,0 +1,83 @@ +class_name SpatialAddress +extends RefCounted + +const SCHEMA_VERSION := 1 + +var _world_id: StringName +var _location_id: StringName +var _position: Vector3 + + +func _init( + world_id: StringName = &"", location_id: StringName = &"", position: Vector3 = Vector3.ZERO +) -> void: + _world_id = world_id + _location_id = location_id + _position = position + + +static func create( + world_id: StringName = &"", location_id: StringName = &"", position: Vector3 = Vector3.ZERO +) -> SpatialAddress: + var address := SpatialAddress.new(world_id, location_id, position) + return address if address.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> SpatialAddress: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all(["world_id", "location_id", "position"]): + return null + var saved_position = record_data["position"] + if not saved_position is Array or saved_position.size() != 3: + return null + return create( + StringName(record_data["world_id"]), + StringName(record_data["location_id"]), + Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2])) + ) + + +func is_valid() -> bool: + return is_finite(_position.x) and is_finite(_position.y) and is_finite(_position.z) + + +func get_world_id() -> StringName: + return _world_id + + +func get_location_id() -> StringName: + return _location_id + + +func get_position() -> Vector3: + return _position + + +func index_key() -> String: + var address_parts: Array = [String(_world_id), String(_location_id)] + if _location_id.is_empty(): + address_parts.append([_position.x, _position.y, _position.z]) + return JSON.stringify(address_parts) + + +func equals(other: SpatialAddress) -> bool: + return ( + other != null + and _world_id == other.get_world_id() + and _location_id == other.get_location_id() + and (_location_id.is_empty() == false or _position == other.get_position()) + ) + + +func duplicate_address() -> SpatialAddress: + return SpatialAddress.new(_world_id, _location_id, _position) + + +func to_dictionary() -> Dictionary: + return { + "schema_version": SCHEMA_VERSION, + "world_id": String(_world_id), + "location_id": String(_location_id), + "position": [_position.x, _position.y, _position.z], + } diff --git a/simulation/state/SpatialAddress.gd.uid b/simulation/state/SpatialAddress.gd.uid new file mode 100644 index 0000000..b017ad9 --- /dev/null +++ b/simulation/state/SpatialAddress.gd.uid @@ -0,0 +1 @@ +uid://ngd5wc8yftsv diff --git a/simulation/state/WorldEntityRef.gd b/simulation/state/WorldEntityRef.gd new file mode 100644 index 0000000..67fd0c4 --- /dev/null +++ b/simulation/state/WorldEntityRef.gd @@ -0,0 +1,63 @@ +class_name WorldEntityRef +extends RefCounted + +const SCHEMA_VERSION := 1 + +var _entity_type: StringName +var _entity_id: StringName + + +func _init(entity_type: StringName = &"", entity_id: StringName = &"") -> void: + _entity_type = entity_type + _entity_id = entity_id + + +static func create(entity_type: StringName, entity_id: StringName) -> WorldEntityRef: + var entity_ref := WorldEntityRef.new(entity_type, entity_id) + return entity_ref if entity_ref.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> WorldEntityRef: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all(["entity_type", "entity_id"]): + return null + return create(StringName(record_data["entity_type"]), StringName(record_data["entity_id"])) + + +func is_valid() -> bool: + return not _entity_type.is_empty() and not _entity_id.is_empty() + + +func get_entity_type() -> StringName: + return _entity_type + + +func get_entity_id() -> StringName: + return _entity_id + + +func index_key() -> String: + var type_text := String(_entity_type) + var id_text := String(_entity_id) + return "%d:%s%d:%s" % [type_text.length(), type_text, id_text.length(), id_text] + + +func equals(other: WorldEntityRef) -> bool: + return ( + other != null + and _entity_type == other.get_entity_type() + and _entity_id == other.get_entity_id() + ) + + +func duplicate_ref() -> WorldEntityRef: + return WorldEntityRef.new(_entity_type, _entity_id) + + +func to_dictionary() -> Dictionary: + return { + "schema_version": SCHEMA_VERSION, + "entity_type": String(_entity_type), + "entity_id": String(_entity_id), + } diff --git a/simulation/state/WorldEntityRef.gd.uid b/simulation/state/WorldEntityRef.gd.uid new file mode 100644 index 0000000..cc6a943 --- /dev/null +++ b/simulation/state/WorldEntityRef.gd.uid @@ -0,0 +1 @@ +uid://dgy40n4mbhb65 diff --git a/simulation/state/WorldEventRecord.gd b/simulation/state/WorldEventRecord.gd new file mode 100644 index 0000000..eb1e0ef --- /dev/null +++ b/simulation/state/WorldEventRecord.gd @@ -0,0 +1,318 @@ +class_name WorldEventRecord +extends RefCounted + +const SCHEMA_VERSION := 1 +const ENTITY_TYPE_NPC := &"npc" +const ENTITY_TYPE_WORLD := &"world_entity" +const ROLE_ACTOR := &"actor" +const ROLE_SOURCE := &"source" +const ROLE_DESTINATION := &"destination" +const ECONOMIC_CORE_FIELDS := [ + "schema_version", + "event_id", + "event_type", + "tick", + "actor_id", + "source_id", + "destination_id", + "world_position", +] + +var _event_id := -1 +var _event_type: StringName +var _tick := -1 +var _participants: Dictionary = {} +var _location: SpatialAddress +var _payload: Dictionary = {} + + +static func create( + event_id: int, + event_type: StringName, + tick: int, + participants: Dictionary = {}, + location: SpatialAddress = null, + payload: Dictionary = {} +) -> WorldEventRecord: + var normalized_participants: Variant = _normalize_participants(participants) + if normalized_participants == null: + return null + var payload_status := [true] + var normalized_payload: Variant = _normalize_serializable_value(payload, payload_status) + if not payload_status[0] or not normalized_payload is Dictionary: + return null + var event := WorldEventRecord.new() + event._event_id = event_id + event._event_type = event_type + event._tick = tick + event._participants = normalized_participants + event._location = location.duplicate_address() if location != null else null + event._payload = normalized_payload + return event if event.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> WorldEventRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all( + ["event_id", "event_type", "tick", "participants", "location", "payload"] + ): + return null + var saved_participants = record_data["participants"] + if not saved_participants is Array: + return null + var participants: Dictionary = {} + for participant_data in saved_participants: + if not participant_data is Dictionary or not participant_data.has_all(["role", "entity"]): + return null + var role := StringName(participant_data["role"]) + if ( + role.is_empty() + or participants.has(role) + or not participant_data["entity"] is Dictionary + ): + return null + var entity_ref := WorldEntityRef.from_dictionary(participant_data["entity"]) + if entity_ref == null: + return null + participants[role] = entity_ref + var location: SpatialAddress + var saved_location = record_data["location"] + if saved_location != null: + if not saved_location is Dictionary: + return null + location = SpatialAddress.from_dictionary(saved_location) + if location == null: + return null + if not record_data["payload"] is Dictionary: + return null + return create( + int(record_data["event_id"]), + StringName(record_data["event_type"]), + int(record_data["tick"]), + participants, + location, + record_data["payload"] + ) + + +static func from_economic_event( + economic_event: EconomicEventRecord, world_id: StringName = &"", location_id: StringName = &"" +) -> WorldEventRecord: + if economic_event == null: + return null + var economic_data := economic_event.to_dictionary() + var participants: Dictionary = { + ROLE_ACTOR: + WorldEntityRef.create(ENTITY_TYPE_NPC, StringName(str(int(economic_data["actor_id"])))) + } + var source_id := StringName(economic_data["source_id"]) + if not source_id.is_empty(): + participants[ROLE_SOURCE] = WorldEntityRef.create(ENTITY_TYPE_WORLD, source_id) + var destination_id := StringName(economic_data["destination_id"]) + if not destination_id.is_empty(): + participants[ROLE_DESTINATION] = WorldEntityRef.create(ENTITY_TYPE_WORLD, destination_id) + var payload: Dictionary = {} + for field in economic_data: + var field_name := String(field) + if field_name in ECONOMIC_CORE_FIELDS: + continue + payload[field_name] = economic_data[field] + var location := SpatialAddress.create( + world_id, location_id, economic_event.get_world_position() + ) + return create( + int(economic_data["event_id"]), + StringName(economic_data["event_type"]), + int(economic_data["tick"]), + participants, + location, + payload + ) + + +func is_valid() -> bool: + if _event_id < 0 or _tick < 0 or _event_type.is_empty(): + return false + if _location != null and not _location.is_valid(): + return false + for role in _participants: + if StringName(role).is_empty(): + return false + var entity_ref := _participants[role] as WorldEntityRef + if entity_ref == null or not entity_ref.is_valid(): + return false + return true + + +func get_event_id() -> int: + return _event_id + + +func get_event_type() -> StringName: + return _event_type + + +func get_tick() -> int: + return _tick + + +func has_location() -> bool: + return _location != null + + +func get_location() -> SpatialAddress: + return _location.duplicate_address() if _location != null else null + + +func get_payload() -> Dictionary: + return _payload.duplicate(true) + + +func get_participant(role: StringName) -> WorldEntityRef: + var entity_ref := _participants.get(role) as WorldEntityRef + return entity_ref.duplicate_ref() if entity_ref != null else null + + +func get_participant_roles() -> Array[StringName]: + var roles: Array[StringName] = [] + for role in _participants: + roles.append(StringName(role)) + roles.sort_custom( + func(left: StringName, right: StringName) -> bool: return String(left) < String(right) + ) + return roles + + +func get_participant_refs() -> Array[WorldEntityRef]: + var refs: Array[WorldEntityRef] = [] + for role in get_participant_roles(): + var entity_ref := _participants[role] as WorldEntityRef + refs.append(entity_ref.duplicate_ref()) + return refs + + +func get_participant_index_keys() -> Array[String]: + var keys: Array[String] = [] + var seen: Dictionary = {} + for role in get_participant_roles(): + var entity_ref := _participants[role] as WorldEntityRef + var key := entity_ref.index_key() + if seen.has(key): + continue + seen[key] = true + keys.append(key) + keys.sort() + return keys + + +func to_economic_event() -> EconomicEventRecord: + var actor := _participants.get(ROLE_ACTOR) as WorldEntityRef + if actor == null: + return null + var actor_text := String(actor.get_entity_id()) + if not actor_text.is_valid_int(): + return null + var source := _participants.get(ROLE_SOURCE) as WorldEntityRef + var destination := _participants.get(ROLE_DESTINATION) as WorldEntityRef + var economic_data := _payload.duplicate(true) + economic_data["schema_version"] = EconomicEventRecord.SCHEMA_VERSION + economic_data["event_id"] = _event_id + economic_data["event_type"] = String(_event_type) + economic_data["tick"] = _tick + economic_data["actor_id"] = actor_text.to_int() + economic_data["source_id"] = String(source.get_entity_id()) if source != null else "" + economic_data["destination_id"] = ( + String(destination.get_entity_id()) if destination != null else "" + ) + economic_data["item_id"] = String(economic_data.get("item_id", "")) + economic_data["amount"] = float(economic_data.get("amount", 0.0)) + economic_data["action_id"] = String(economic_data.get("action_id", "")) + economic_data["required_amount"] = float(economic_data.get("required_amount", 0.0)) + var position := _location.get_position() if _location != null else Vector3.ZERO + economic_data["world_position"] = [position.x, position.y, position.z] + return EconomicEventRecord.from_dictionary(economic_data) + + +func to_dictionary() -> Dictionary: + var serialized_participants: Array[Dictionary] = [] + for role in get_participant_roles(): + var entity_ref := _participants[role] as WorldEntityRef + ( + serialized_participants + . append( + { + "role": String(role), + "entity": entity_ref.to_dictionary(), + } + ) + ) + return { + "schema_version": SCHEMA_VERSION, + "event_id": _event_id, + "event_type": String(_event_type), + "tick": _tick, + "participants": serialized_participants, + "location": _location.to_dictionary() if _location != null else null, + "payload": _payload.duplicate(true), + } + + +static func _normalize_participants(participants: Dictionary) -> Variant: + var normalized: Dictionary = {} + var role_names: Dictionary = {} + for raw_role in participants: + if not raw_role is String and not raw_role is StringName: + return null + var role := StringName(raw_role) + var role_text := String(role) + if role.is_empty() or role_names.has(role_text): + return null + var entity_ref := participants[raw_role] as WorldEntityRef + if entity_ref == null or not entity_ref.is_valid(): + return null + role_names[role_text] = true + normalized[role] = entity_ref.duplicate_ref() + return normalized + + +static func _normalize_serializable_value(value: Variant, status: Array) -> Variant: + match typeof(value): + TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_STRING: + return value + TYPE_FLOAT: + if not is_finite(float(value)): + status[0] = false + return value + TYPE_STRING_NAME: + return String(value) + TYPE_ARRAY: + var normalized_array: Array = [] + for element in value: + normalized_array.append(_normalize_serializable_value(element, status)) + return normalized_array + TYPE_DICTIONARY: + return _normalize_serializable_dictionary(value, status) + _: + status[0] = false + return null + + +static func _normalize_serializable_dictionary(value: Dictionary, status: Array) -> Dictionary: + var source_keys: Dictionary = {} + for raw_key in value: + if not raw_key is String and not raw_key is StringName: + status[0] = false + return {} + var key_text := String(raw_key) + if source_keys.has(key_text): + status[0] = false + return {} + source_keys[key_text] = raw_key + var sorted_keys: Array = source_keys.keys() + sorted_keys.sort() + var normalized: Dictionary = {} + for key_text in sorted_keys: + var raw_key = source_keys[key_text] + normalized[key_text] = _normalize_serializable_value(value[raw_key], status) + return normalized diff --git a/simulation/state/WorldEventRecord.gd.uid b/simulation/state/WorldEventRecord.gd.uid new file mode 100644 index 0000000..40c1956 --- /dev/null +++ b/simulation/state/WorldEventRecord.gd.uid @@ -0,0 +1 @@ +uid://c2txwx7jgo8rx diff --git a/tests/content_catalog_test.gd b/tests/content_catalog_test.gd new file mode 100644 index 0000000..243720e --- /dev/null +++ b/tests/content_catalog_test.gd @@ -0,0 +1,270 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_core_pack() + _test_deterministic_enumeration_and_dependencies() + _test_duplicate_ids_fail_without_overrides() + _test_unknown_references_categories_and_handlers() + _finish() + + +func _test_core_pack() -> void: + var catalog := ContentCatalog.create_core() + _check(catalog.is_valid(), "Core pack should validate: %s" % [catalog.get_errors()]) + _check( + ( + _action_ids(catalog.get_actions()) + == [ + &"defend", + &"deposit_food", + &"deposit_wood", + &"eat", + &"feed_animal", + &"gather_food", + &"gather_wood", + &"patrol", + &"rest", + &"sleep", + &"study", + &"wander", + &"withdraw_food", + ] + ), + "Core actions should enumerate by stable ID" + ) + _check( + ( + _profession_ids(catalog.get_professions()) + == [&"farmer", &"guard", &"scholar", &"wanderer", &"woodcutter"] + ), + "Core professions should enumerate by stable ID" + ) + _check( + _item_ids(catalog.get_items()) == [&"food", &"item_claw", &"item_sword", &"wood"], + "Core items should include food, wood, sword, and claw in stable-ID order" + ) + _check( + _enemy_ids(catalog.get_enemies()) == [&"enemy_raider", &"enemy_wolf"], + "Core enemies should enumerate by stable ID" + ) + var food := catalog.get_item(SimulationIds.RESOURCE_FOOD) + var wood := catalog.get_item(SimulationIds.RESOURCE_WOOD) + var sword := catalog.get_item(SimulationIds.ITEM_SWORD) + var claw := catalog.get_item(SimulationIds.ITEM_CLAW) + _check(food != null and food.is_resource(), "Food should be a resource item") + _check(wood != null and wood.is_resource(), "Wood should be a resource item") + _check( + sword != null and sword.is_weapon() and is_equal_approx(sword.damage, 24.0), + "Sword should preserve its weapon contract" + ) + _check( + claw != null and claw.is_weapon() and is_equal_approx(claw.reach, 1.2), + "Claw should preserve its weapon contract" + ) + var wolf := catalog.get_enemy(&"enemy_wolf") + _check( + wolf != null and wolf.weapon_id == SimulationIds.ITEM_CLAW, + "Enemy references should resolve against catalog items" + ) + + +func _test_deterministic_enumeration_and_dependencies() -> void: + var core := load(ContentCatalog.CORE_PACK_PATH) as SimulationContentPack + var addon := _pack(&"addon", "Addon") + addon.required_pack_ids = [&"core"] + addon.required_handler_ids = [&"test_handler"] + addon.items = [_resource_item(&"amber", "Amber")] + var packs: Array[SimulationContentPack] = [addon, core] + var catalog := ContentCatalog.new() + var handlers: Array[StringName] = [&"test_handler"] + var errors := catalog.rebuild(packs, handlers) + _check(errors.is_empty(), "Known pack and handler dependencies should validate: %s" % [errors]) + _check( + _pack_ids(catalog.get_packs()) == [&"addon", &"core"], + "Pack enumeration should not depend on input order" + ) + _check( + _item_ids(catalog.get_items()) == [&"amber", &"food", &"item_claw", &"item_sword", &"wood"], + "Merged item enumeration should be globally stable-ID sorted" + ) + var returned_items := catalog.get_items() + returned_items.clear() + _check( + catalog.get_items().size() == 5, + "Callers should not be able to mutate the catalog's ordered arrays" + ) + + +func _test_duplicate_ids_fail_without_overrides() -> void: + var first := _pack(&"first", "First") + first.items = [_resource_item(&"shared_item", "First value")] + var second := _pack(&"second", "Second") + second.items = [_resource_item(&"shared_item", "Second value")] + var packs: Array[SimulationContentPack] = [second, first] + var catalog := ContentCatalog.new() + var errors := catalog.rebuild(packs) + _check(_has_error(errors, "Duplicate item_id 'shared_item'"), "Duplicate item IDs should fail") + _check( + ( + not catalog.is_valid() + and catalog.get_item(&"shared_item") == null + and catalog.get_items().is_empty() + ), + "An invalid build should publish neither duplicate value nor partial content" + ) + + var duplicate_pack := _pack(&"first", "Duplicate pack") + var duplicate_packs: Array[SimulationContentPack] = [first, duplicate_pack] + errors = catalog.rebuild(duplicate_packs) + _check(_has_error(errors, "Duplicate pack_id 'first'"), "Duplicate pack IDs should fail") + + +func _test_unknown_references_categories_and_handlers() -> void: + var broken := _pack(&"broken", "Broken") + broken.required_pack_ids = [&"missing_pack"] + broken.required_handler_ids = [&"missing_handler"] + var invalid_item := _resource_item(&"strange_item", "Strange") + invalid_item.category = &"unknown_category" + var ordinary_resource := _resource_item(&"ordinary_resource", "Ordinary resource") + var missing_profession := _action(&"missing_profession") + missing_profession.preferred_profession_id = &"unknown_profession" + var missing_action := _action(&"missing_action") + missing_action.target_type = SimulationIds.TARGET_RESOURCE + missing_action.resource_action_id = &"unknown_resource_action" + 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 + 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 + weapon_cost.completion_cost_amount = 1.0 + var missing_weapon := _enemy(&"missing_weapon_enemy", &"unknown_weapon") + var non_weapon := _enemy(&"non_weapon_enemy", ordinary_resource.item_id) + broken.items = [invalid_item, ordinary_resource, weapon_item] + broken.actions = [missing_profession, missing_action, missing_cost_item, weapon_cost] + broken.enemies = [missing_weapon, non_weapon] + var packs: Array[SimulationContentPack] = [broken] + var known_handlers: Array[StringName] = [&"known_handler"] + var catalog := ContentCatalog.new() + var errors := catalog.rebuild(packs, known_handlers) + for expected in [ + "unknown required pack 'missing_pack'", + "unknown required handler 'missing_handler'", + "category 'unknown_category' is invalid", + "unknown profession 'unknown_profession'", + "unknown resource action 'unknown_resource_action'", + "unknown completion cost item 'unknown_cost_item'", + "non-resource completion cost item 'test_weapon'", + "unknown weapon 'unknown_weapon'", + "non-weapon item 'ordinary_resource'", + ]: + _check(_has_error(errors, expected), "Catalog should report %s: %s" % [expected, errors]) + _check(not catalog.is_valid(), "Unknown references should invalidate the complete catalog") + + +func _pack(pack_id: StringName, display_name: String) -> SimulationContentPack: + var pack := SimulationContentPack.new() + pack.pack_id = pack_id + pack.display_name = display_name + return pack + + +func _resource_item(item_id: StringName, display_name: String) -> ItemDefinition: + var item := ItemDefinition.new() + item.item_id = item_id + item.display_name = display_name + item.category = ItemDefinition.CATEGORY_RESOURCE + return item + + +func _weapon_item(item_id: StringName, display_name: String) -> ItemDefinition: + var item := ItemDefinition.new() + item.item_id = item_id + item.display_name = display_name + item.category = ItemDefinition.CATEGORY_WEAPON + item.equip_slot = SimulationIds.EQUIP_SLOT_HAND + item.damage = 1.0 + item.reach = 1.0 + item.attack_cooldown = 1.0 + return item + + +func _action(action_id: StringName) -> ActionDefinition: + var action := ActionDefinition.new() + action.action_id = action_id + action.display_name = String(action_id).capitalize() + action.target_type = SimulationIds.TARGET_ACTIVITY + return action + + +func _enemy(enemy_id: StringName, weapon_id: StringName) -> EnemyDefinition: + var enemy := EnemyDefinition.new() + enemy.enemy_id = enemy_id + enemy.display_name = String(enemy_id).capitalize() + enemy.kind = SimulationIds.COMBATANT_KIND_RAIDER + enemy.weapon_id = weapon_id + return enemy + + +func _pack_ids(packs: Array[SimulationContentPack]) -> Array[StringName]: + var ids: Array[StringName] = [] + for pack in packs: + ids.append(pack.pack_id) + return ids + + +func _action_ids(actions: Array[ActionDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for action in actions: + ids.append(action.action_id) + return ids + + +func _profession_ids(professions: Array[ProfessionDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for profession in professions: + ids.append(profession.profession_id) + return ids + + +func _item_ids(items: Array[ItemDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for item in items: + ids.append(item.item_id) + return ids + + +func _enemy_ids(enemies: Array[EnemyDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for enemy in enemies: + ids.append(enemy.enemy_id) + return ids + + +func _has_error(errors: Array[String], fragment: String) -> bool: + for error in errors: + if fragment in error: + return true + return false + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Content catalog passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/content_catalog_test.gd.uid b/tests/content_catalog_test.gd.uid new file mode 100644 index 0000000..00ce482 --- /dev/null +++ b/tests/content_catalog_test.gd.uid @@ -0,0 +1 @@ +uid://cvwpt4l3vp4ir diff --git a/tests/unit/test_world_event_store.gd b/tests/unit/test_world_event_store.gd new file mode 100644 index 0000000..abda607 --- /dev/null +++ b/tests/unit/test_world_event_store.gd @@ -0,0 +1,172 @@ +extends GutTest + + +func test_value_objects_round_trip_and_reject_invalid_data() -> void: + var entity_ref := WorldEntityRef.create(&"npc", &"7") + var address := SpatialAddress.create(&"jajce", &"village_square", Vector3(2.0, 3.0, 4.0)) + + assert_not_null(entity_ref) + assert_eq( + WorldEntityRef.from_dictionary(entity_ref.to_dictionary()).to_dictionary(), + entity_ref.to_dictionary() + ) + assert_not_null(address) + assert_eq( + SpatialAddress.from_dictionary(address.to_dictionary()).to_dictionary(), + address.to_dictionary() + ) + assert_null(WorldEntityRef.create(&"npc", &"")) + assert_null( + ( + SpatialAddress + . from_dictionary( + { + "schema_version": SpatialAddress.SCHEMA_VERSION, + "world_id": "jajce", + "location_id": "square", + "position": [0.0, 1.0], + } + ) + ) + ) + + +func test_record_serialization_is_deterministic_and_defensive() -> void: + var actor := WorldEntityRef.create(&"npc", &"7") + var resource := WorldEntityRef.create(&"resource", &"berry_patch_a") + var participants := { + &"source": resource, + &"actor": actor, + } + var payload := { + &"zeta": 2, + &"alpha": &"food", + &"nested": {&"beta": 2, &"alpha": 1}, + } + var event := WorldEventRecord.create( + 42, + &"resource_extracted", + 12, + participants, + SpatialAddress.create(&"jajce", &"forest_edge", Vector3(4.0, 0.0, 8.0)), + payload + ) + + participants.clear() + payload[&"zeta"] = 99 + var serialized := event.to_dictionary() + var restored := WorldEventRecord.from_dictionary(serialized) + assert_not_null(restored) + assert_eq(restored.to_dictionary(), serialized) + assert_eq(JSON.stringify(restored.to_dictionary()), JSON.stringify(serialized)) + assert_eq(restored.get_participant_roles(), [&"actor", &"source"]) + assert_eq(restored.get_payload()["zeta"], 2) + assert_eq(restored.get_payload()["alpha"], "food") + serialized["payload"]["zeta"] = 100 + assert_eq(restored.get_payload()["zeta"], 2) + + +func test_store_indexes_id_type_participant_location_and_tick() -> void: + var store := WorldEventStore.new() + var actor_a := WorldEntityRef.create(&"npc", &"7") + var actor_b := WorldEntityRef.create(&"npc", &"8") + var pantry := WorldEntityRef.create(&"storage", &"village_pantry") + var square := SpatialAddress.create(&"jajce", &"village_square", Vector3.ZERO) + var forest := SpatialAddress.create(&"jajce", &"forest_edge", Vector3(12.0, 0.0, 4.0)) + var later_event := WorldEventRecord.create( + 20, + &"storage_deposited", + 9, + {&"actor": actor_a, &"destination": pantry}, + square, + {"item_id": "food", "amount": 2.0} + ) + var earlier_event := WorldEventRecord.create( + 7, &"resource_extracted", 4, {&"actor": actor_a}, forest, {"item_id": "food", "amount": 2.0} + ) + var same_tick_event := WorldEventRecord.create( + 9, + &"storage_deposited", + 9, + {&"actor": actor_b, &"source": pantry}, + square, + {"item_id": "food", "amount": 1.0} + ) + + assert_true(store.append(later_event)) + assert_true(store.append(earlier_event)) + assert_true(store.append(same_tick_event)) + assert_false(store.append(later_event)) + assert_same(store.get_by_id(20), store.get_by_id(20)) + assert_eq(_event_ids(store.get_all()), [7, 9, 20]) + assert_eq(_event_ids(store.get_for_type(&"storage_deposited")), [9, 20]) + assert_eq(_event_ids(store.get_for_participant(actor_a)), [7, 20]) + assert_eq(_event_ids(store.get_for_participant(pantry)), [9, 20]) + assert_eq(_event_ids(store.get_for_location(square)), [9, 20]) + assert_eq(_event_ids(store.get_for_tick(9)), [9, 20]) + assert_eq(_event_ids(store.get_between_ticks(4, 9)), [7, 9, 20]) + assert_true(store.get_for_type(&"unknown").is_empty()) + + +func test_store_round_trip_rebuilds_all_indexes_transactionally() -> void: + var original := WorldEventStore.new() + var actor := WorldEntityRef.create(&"npc", &"3") + var address := SpatialAddress.create(&"jajce", &"pasture", Vector3(1.0, 0.0, 2.0)) + assert_true( + original.append( + WorldEventRecord.create( + 5, &"animal_fed", 14, {&"actor": actor}, address, {"amount": 1.0} + ) + ) + ) + var serialized := original.to_dictionary() + var restored := WorldEventStore.from_dictionary(serialized) + + assert_not_null(restored) + assert_eq(restored.to_dictionary(), serialized) + assert_eq(_event_ids(restored.get_for_type(&"animal_fed")), [5]) + assert_eq(_event_ids(restored.get_for_participant(actor)), [5]) + assert_eq(_event_ids(restored.get_for_location(address)), [5]) + assert_eq(_event_ids(restored.get_for_tick(14)), [5]) + + var invalid_restore := { + "schema_version": WorldEventStore.SCHEMA_VERSION, + "events": [serialized["events"][0], serialized["events"][0]], + } + assert_false(restored.restore_from_dictionary(invalid_restore)) + assert_eq(restored.to_dictionary(), serialized) + + +func test_economic_event_compatibility_conversion_preserves_contract() -> void: + var economic := EconomicEventRecord.create( + 12, + &"storage_deposited", + 30, + 4, + &"npc_inventory_4", + &"village_pantry", + &"food", + 2.5, + Vector3(3.0, 0.0, 7.0) + ) + economic.data["custom_fact"] = {"reason": "harvest"} + var world_event := WorldEventRecord.from_economic_event(economic, &"jajce", &"village_pantry") + var round_tripped := world_event.to_economic_event() + + assert_not_null(world_event) + assert_eq(world_event.get_event_id(), 12) + assert_eq(world_event.get_participant(&"actor").get_entity_id(), &"4") + assert_eq(world_event.get_participant(&"source").get_entity_id(), &"npc_inventory_4") + assert_eq(world_event.get_participant(&"destination").get_entity_id(), &"village_pantry") + assert_eq(round_tripped.to_dictionary(), economic.to_dictionary()) + + var store := WorldEventStore.new() + assert_not_null(store.append_economic(economic, &"jajce", &"village_pantry")) + assert_eq(store.get_by_id(12).to_economic_event().to_dictionary(), economic.to_dictionary()) + + +func _event_ids(events: Array[WorldEventRecord]) -> Array[int]: + var ids: Array[int] = [] + for event in events: + ids.append(event.get_event_id()) + return ids diff --git a/tests/unit/test_world_event_store.gd.uid b/tests/unit/test_world_event_store.gd.uid new file mode 100644 index 0000000..34aa9cb --- /dev/null +++ b/tests/unit/test_world_event_store.gd.uid @@ -0,0 +1 @@ +uid://1tp4ttukj4bv diff --git a/tests/world_target_registry_test.gd b/tests/world_target_registry_test.gd new file mode 100644 index 0000000..c303a73 --- /dev/null +++ b/tests/world_target_registry_test.gd @@ -0,0 +1,256 @@ +extends SceneTree + +const CapabilityScript := preload("res://world/targets/WorldTargetCapability.gd") +const DescriptorScript := preload("res://world/targets/WorldTargetDescriptor.gd") +const RegistryScript := preload("res://world/targets/WorldTargetRegistry.gd") +const ActionCommandScript := preload("res://simulation/commands/ActionCommand.gd") +const ActionResultScript := preload("res://simulation/commands/ActionResult.gd") +const ActionOfferScript := preload("res://simulation/commands/ActionOffer.gd") +const InteractionServiceScript := preload("res://simulation/commands/InteractionService.gd") +const ActionCommandServiceScript := preload("res://simulation/commands/ActionCommandService.gd") + +var failures: Array[String] = [] + + +func _initialize() -> void: + _test_canonical_lookup_and_cross_kind_uniqueness() + _test_deterministic_iteration_and_capability_updates() + _test_transient_handle_lifecycle() + _test_two_world_isolation() + _test_descriptors_and_commands_are_non_authoritative_values() + _finish() + + +func _test_canonical_lookup_and_cross_kind_uniqueness() -> void: + var registry := RegistryScript.new(&"jajce") + var descriptor: WorldTargetDescriptor = _descriptor( + &"shared_target", &"resource", [&"gather_food"], Vector3(2.0, 0.0, 3.0) + ) + var handle = registry.register_target(descriptor) + _check(handle != null, "A valid target should register") + _check(registry.size() == 1, "The registry should contain one canonical target") + _check( + registry.get_descriptor(&"shared_target").get_target_kind() == &"resource", + "Canonical lookup should return the registered descriptor" + ) + var conflicting: WorldTargetDescriptor = _descriptor( + &"shared_target", &"storage", [&"deposit_food"] + ) + _check( + registry.register_target(conflicting) == null, + "One target ID must not register under a second kind in the same context" + ) + _check( + registry.last_error.contains("already registered as kind 'resource'"), + "A cross-kind collision should expose a useful non-diagnostic error" + ) + _check(registry.size() == 1, "A rejected collision must not replace the canonical entry") + + +func _test_deterministic_iteration_and_capability_updates() -> void: + var registry := RegistryScript.new(&"ordered_world") + registry.register_target(_descriptor(&"target_z", &"resource", [&"gather_food"])) + var middle_handle = registry.register_target( + _descriptor(&"target_m", &"activity", [&"rest", &"socialize"]) + ) + registry.register_target(_descriptor(&"target_a", &"resource", [&"gather_food"])) + + _check( + registry.get_target_ids() == [&"target_a", &"target_m", &"target_z"], + "Iteration should be stable-ID ordered regardless of registration order" + ) + _check( + registry.get_target_ids(&"resource") == [&"target_a", &"target_z"], + "Kind-filtered iteration should remain deterministic" + ) + _check( + registry.get_target_ids(&"", &"gather_food") == [&"target_a", &"target_z"], + "Capability-filtered iteration should remain deterministic" + ) + var updated: WorldTargetDescriptor = _descriptor( + &"target_m", &"activity", [&"trade"], Vector3(8.0, 0.0, -2.0) + ) + _check(registry.update_target(middle_handle, updated), "A live handle should update facts") + _check( + registry.get_target_ids(&"", &"socialize").is_empty(), + "Updating capabilities should remove old capability-index membership" + ) + _check( + registry.get_target_ids(&"", &"trade") == [&"target_m"], + "Updating capabilities should add new capability-index membership" + ) + _check( + registry.resolve_handle(middle_handle).get_local_position() == Vector3(8.0, 0.0, -2.0), + "A target update should preserve handle identity while replacing transient facts" + ) + + +func _test_transient_handle_lifecycle() -> void: + var registry := RegistryScript.new(&"lifecycle_world") + var first_provider := Node.new() + var first_handle = registry.register_target( + _descriptor(&"workbench", &"activity", [&"craft"]), first_provider + ) + _check( + registry.is_handle_valid(first_handle, true), + "A newly registered provider handle should resolve" + ) + _check( + registry.get_provider(first_handle) == first_provider, + "A handle should resolve its provider" + ) + _check( + registry.unregister_target(first_handle), "The current handle should unregister its target" + ) + _check( + not registry.is_handle_valid(first_handle), "Unregistration should invalidate the handle" + ) + + var second_provider := Node.new() + var second_handle = registry.register_target( + _descriptor(&"workbench", &"activity", [&"craft"]), second_provider + ) + _check( + second_handle.get_generation() > first_handle.get_generation(), + "Re-registration should advance the transient generation" + ) + _check( + not registry.unregister_target(first_handle), + "A stale provider must not unregister a newer canonical target" + ) + _check( + registry.is_handle_valid(second_handle, true), + "Rejecting a stale handle should leave the newer target intact" + ) + second_provider.free() + _check( + registry.prune_invalid_providers() == [&"workbench"], + "Dead weak providers should be pruned deterministically" + ) + _check(not registry.is_handle_valid(second_handle), "Pruning should invalidate its handle") + first_provider.free() + + +func _test_two_world_isolation() -> void: + var first_world := RegistryScript.new(&"world_one") + var second_world := RegistryScript.new(&"world_two") + var first_handle = first_world.register_target( + _descriptor(&"village_pantry", &"storage", [&"withdraw_food"]) + ) + var second_handle = second_world.register_target( + _descriptor(&"village_pantry", &"landmark", [&"inspect"]) + ) + + _check( + first_handle != null and second_handle != null, "Two contexts may reuse a local target ID" + ) + _check( + first_world.resolve_handle(second_handle) == null, + "A handle from another registry must never resolve, even with the same target ID" + ) + _check( + second_world.resolve_handle(first_handle) == null, + "Registry ownership should isolate handles in both directions" + ) + _check( + first_world.unregister_target(first_handle), + "The first world should unregister independently" + ) + _check( + second_world.is_handle_valid(second_handle), + "Unloading one context must not affect a second context's canonical target" + ) + _check( + second_world.get_descriptor(&"village_pantry").get_target_kind() == &"landmark", + "Each context should retain its own target facts" + ) + + +func _test_descriptors_and_commands_are_non_authoritative_values() -> void: + var attributes := {"amounts": [1, 2]} + var metadata := {"tags": ["village"]} + var capability := CapabilityScript.new(&"inspect", attributes) + var descriptor := DescriptorScript.new( + &"notice_board", &"activity", [capability], Vector3.ZERO, "Notice board", metadata + ) + attributes["amounts"][0] = 99 + metadata["tags"].append("mutated") + var registry := RegistryScript.new(&"command_world") + var handle = registry.register_target(descriptor) + var stored := registry.get_descriptor(&"notice_board") + _check( + stored.get_capability(&"inspect").get_attribute(&"amounts") == [1, 2], + "Capability attributes should be isolated from caller mutation" + ) + _check( + stored.get_metadata_value(&"tags") == ["village"], + "Target metadata should be isolated from caller mutation" + ) + + var offer_parameters := {"page": 1} + var offer := ActionOfferScript.new( + &"inspect_board", &"inspect", handle, "Read notices", true, "", offer_parameters, 2.0 + ) + offer_parameters["page"] = 9 + _check(offer.is_valid(), "A well-formed advisory action offer should be valid") + _check(offer.get_parameters()["page"] == 1, "An offer should copy its parameter value") + var command_parameters := {"page": 1} + var command := ActionCommandScript.new( + &"command_1", &"person_7", offer.get_action_id(), offer.get_target(), command_parameters + ) + command_parameters["page"] = 5 + _check(command.is_valid(), "A command should carry a well-formed transient target intent") + _check(command.get_parameters()["page"] == 1, "A command should copy its parameter value") + _check(registry.unregister_target(handle), "The offered target should be removable") + _check( + command.is_valid() and not registry.is_handle_valid(command.get_target()), + "Command shape validity must not make a stale target authoritative" + ) + + var accepted := ActionResultScript.accepted(&"command_1", {"queued": true}) + var rejected := ActionResultScript.rejected(&"command_2", &"stale_target", "Try again") + _check(accepted.is_valid() and accepted.is_accepted(), "Accepted results should be explicit") + _check( + rejected.is_valid() and rejected.is_terminal() and not rejected.is_accepted(), + "Rejected results should carry a terminal, non-success status" + ) + _check( + ( + InteractionServiceScript + . new() + . get_action_offers(&"person_7", command.get_target()) + . is_empty() + ), + "The manager-independent interaction interface should have no implicit offers" + ) + _check( + ActionCommandServiceScript.new().submit_command(command) == null, + "The manager-independent command interface should have no implicit authority" + ) + + +func _descriptor( + target_id: StringName, + target_kind: StringName, + capability_ids: Array[StringName], + position: Vector3 = Vector3.ZERO +) -> WorldTargetDescriptor: + var capabilities: Array = [] + for capability_id in capability_ids: + capabilities.append(CapabilityScript.new(capability_id)) + return DescriptorScript.new(target_id, target_kind, capabilities, position) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] World target registry passed: scoped canonical IDs, handles, and commands") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/world_target_registry_test.gd.uid b/tests/world_target_registry_test.gd.uid new file mode 100644 index 0000000..a219294 --- /dev/null +++ b/tests/world_target_registry_test.gd.uid @@ -0,0 +1 @@ +uid://d3linx25d7web diff --git a/world/targets/WorldTargetCapability.gd b/world/targets/WorldTargetCapability.gd new file mode 100644 index 0000000..1bb6f00 --- /dev/null +++ b/world/targets/WorldTargetCapability.gd @@ -0,0 +1,52 @@ +class_name WorldTargetCapability +extends RefCounted + +var _capability_id: StringName +var _attributes: Dictionary + + +func _init(capability_id: StringName = &"", attributes: Dictionary = {}) -> void: + _capability_id = capability_id + _attributes = attributes.duplicate(true) + + +func is_valid() -> bool: + return not _capability_id.is_empty() and not _contains_object(_attributes) + + +func get_capability_id() -> StringName: + return _capability_id + + +func get_attributes() -> Dictionary: + return _attributes.duplicate(true) + + +func get_attribute(attribute_id: StringName, default_value: Variant = null) -> Variant: + var value: Variant = _attributes.get(String(attribute_id), default_value) + return value.duplicate(true) if value is Array or value is Dictionary else value + + +func to_dictionary() -> Dictionary: + return { + "capability_id": String(_capability_id), + "attributes": _attributes.duplicate(true), + } + + +func copy() -> WorldTargetCapability: + return WorldTargetCapability.new(_capability_id, _attributes) + + +static func _contains_object(value: Variant) -> bool: + if value is Object: + return true + if value is Array: + for item in value: + if _contains_object(item): + return true + elif value is Dictionary: + for key in value: + if _contains_object(key) or _contains_object(value[key]): + return true + return false diff --git a/world/targets/WorldTargetCapability.gd.uid b/world/targets/WorldTargetCapability.gd.uid new file mode 100644 index 0000000..0327f09 --- /dev/null +++ b/world/targets/WorldTargetCapability.gd.uid @@ -0,0 +1 @@ +uid://cfs6efk5rokcp diff --git a/world/targets/WorldTargetDescriptor.gd b/world/targets/WorldTargetDescriptor.gd new file mode 100644 index 0000000..7f49d7a --- /dev/null +++ b/world/targets/WorldTargetDescriptor.gd @@ -0,0 +1,126 @@ +class_name WorldTargetDescriptor +extends RefCounted + +var _target_id: StringName +var _target_kind: StringName +var _display_name: String +var _local_position: Vector3 +var _metadata: Dictionary +var _capabilities_by_id: Dictionary = {} +var _capability_ids: Array[StringName] = [] +var _is_valid := false + + +func _init( + target_id: StringName = &"", + target_kind: StringName = &"", + capabilities: Array = [], + local_position: Vector3 = Vector3.ZERO, + display_name: String = "", + metadata: Dictionary = {} +) -> void: + _target_id = target_id + _target_kind = target_kind + _display_name = display_name + _local_position = local_position + _metadata = metadata.duplicate(true) + _is_valid = ( + not _target_id.is_empty() + and not _target_kind.is_empty() + and _local_position.is_finite() + and not WorldTargetCapability._contains_object(_metadata) + ) + for value in capabilities: + var capability := value as WorldTargetCapability + if ( + capability == null + or not capability.is_valid() + or _capabilities_by_id.has(capability.get_capability_id()) + ): + _is_valid = false + continue + var isolated_capability := capability.copy() + _capabilities_by_id[isolated_capability.get_capability_id()] = isolated_capability + _capability_ids.append(isolated_capability.get_capability_id()) + _capability_ids.sort_custom(_id_less) + + +func is_valid() -> bool: + return _is_valid + + +func get_target_id() -> StringName: + return _target_id + + +func get_target_kind() -> StringName: + return _target_kind + + +func get_display_name() -> String: + return _display_name + + +func get_local_position() -> Vector3: + return _local_position + + +func get_metadata() -> Dictionary: + return _metadata.duplicate(true) + + +func get_metadata_value(metadata_id: StringName, default_value: Variant = null) -> Variant: + var value: Variant = _metadata.get(String(metadata_id), default_value) + return value.duplicate(true) if value is Array or value is Dictionary else value + + +func has_capability(capability_id: StringName) -> bool: + return _capabilities_by_id.has(capability_id) + + +func get_capability(capability_id: StringName) -> WorldTargetCapability: + var capability := _capabilities_by_id.get(capability_id) as WorldTargetCapability + return capability.copy() if capability != null else null + + +func get_capability_ids() -> Array[StringName]: + return _capability_ids.duplicate() + + +func get_capabilities() -> Array[WorldTargetCapability]: + var capabilities: Array[WorldTargetCapability] = [] + for capability_id in _capability_ids: + capabilities.append((_capabilities_by_id[capability_id] as WorldTargetCapability).copy()) + return capabilities + + +func copy() -> WorldTargetDescriptor: + return WorldTargetDescriptor.new( + _target_id, _target_kind, get_capabilities(), _local_position, _display_name, _metadata + ) + + +func with_local_position(next_position: Vector3) -> WorldTargetDescriptor: + return WorldTargetDescriptor.new( + _target_id, _target_kind, get_capabilities(), next_position, _display_name, _metadata + ) + + +func to_dictionary() -> Dictionary: + var capabilities: Array[Dictionary] = [] + for capability_id in _capability_ids: + capabilities.append( + (_capabilities_by_id[capability_id] as WorldTargetCapability).to_dictionary() + ) + return { + "target_id": String(_target_id), + "target_kind": String(_target_kind), + "display_name": _display_name, + "local_position": [_local_position.x, _local_position.y, _local_position.z], + "metadata": _metadata.duplicate(true), + "capabilities": capabilities, + } + + +static func _id_less(first: StringName, second: StringName) -> bool: + return String(first) < String(second) diff --git a/world/targets/WorldTargetDescriptor.gd.uid b/world/targets/WorldTargetDescriptor.gd.uid new file mode 100644 index 0000000..97bc4c2 --- /dev/null +++ b/world/targets/WorldTargetDescriptor.gd.uid @@ -0,0 +1 @@ +uid://bjvptxki1v7q0 diff --git a/world/targets/WorldTargetHandle.gd b/world/targets/WorldTargetHandle.gd new file mode 100644 index 0000000..bd7f8a3 --- /dev/null +++ b/world/targets/WorldTargetHandle.gd @@ -0,0 +1,52 @@ +class_name WorldTargetHandle +extends RefCounted + +var _registry_instance_id: int +var _context_id: StringName +var _target_id: StringName +var _target_kind: StringName +var _generation: int + + +func _init( + registry_instance_id: int = 0, + context_id: StringName = &"", + target_id: StringName = &"", + target_kind: StringName = &"", + generation: int = 0 +) -> void: + _registry_instance_id = registry_instance_id + _context_id = context_id + _target_id = target_id + _target_kind = target_kind + _generation = generation + + +func is_well_formed() -> bool: + return ( + _registry_instance_id != 0 + and not _context_id.is_empty() + and not _target_id.is_empty() + and not _target_kind.is_empty() + and _generation > 0 + ) + + +func get_registry_instance_id() -> int: + return _registry_instance_id + + +func get_context_id() -> StringName: + return _context_id + + +func get_target_id() -> StringName: + return _target_id + + +func get_target_kind() -> StringName: + return _target_kind + + +func get_generation() -> int: + return _generation diff --git a/world/targets/WorldTargetHandle.gd.uid b/world/targets/WorldTargetHandle.gd.uid new file mode 100644 index 0000000..6e4e0dc --- /dev/null +++ b/world/targets/WorldTargetHandle.gd.uid @@ -0,0 +1 @@ +uid://dcun0sd0f8ext diff --git a/world/targets/WorldTargetRegistry.gd b/world/targets/WorldTargetRegistry.gd new file mode 100644 index 0000000..5199090 --- /dev/null +++ b/world/targets/WorldTargetRegistry.gd @@ -0,0 +1,263 @@ +class_name WorldTargetRegistry +extends RefCounted + +var last_error := "" + +var _context_id: StringName +var _entries_by_id: Dictionary = {} +var _generation_by_id: Dictionary = {} +var _sorted_target_ids: Array[StringName] = [] +var _target_ids_by_kind: Dictionary = {} +var _target_ids_by_capability: Dictionary = {} + + +func _init(owned_context_id: StringName = &"") -> void: + _context_id = owned_context_id + + +func register_target( + descriptor: WorldTargetDescriptor, provider: Object = null +) -> WorldTargetHandle: + last_error = "" + if _context_id.is_empty(): + return _fail_handle("Registry context_id must not be empty") + if descriptor == null or not descriptor.is_valid(): + return _fail_handle("Target descriptor is invalid") + if provider != null and not is_instance_valid(provider): + return _fail_handle("Target provider is invalid") + var target_id := descriptor.get_target_id() + if _entries_by_id.has(target_id): + var existing := _entries_by_id[target_id]["descriptor"] as WorldTargetDescriptor + return _fail_handle( + ( + "Target ID '%s' is already registered as kind '%s'" + % [target_id, existing.get_target_kind()] + ) + ) + + var isolated_descriptor := descriptor.copy() + var generation := int(_generation_by_id.get(target_id, 0)) + 1 + _generation_by_id[target_id] = generation + _entries_by_id[target_id] = { + "descriptor": isolated_descriptor, + "generation": generation, + "has_provider": provider != null, + "provider_ref": weakref(provider) if provider != null else null, + } + _insert_sorted_unique(_sorted_target_ids, target_id) + _add_to_index(_target_ids_by_kind, isolated_descriptor.get_target_kind(), target_id) + for capability_id in isolated_descriptor.get_capability_ids(): + _add_to_index(_target_ids_by_capability, capability_id, target_id) + return _make_handle(isolated_descriptor, generation) + + +func update_target(handle: WorldTargetHandle, descriptor: WorldTargetDescriptor) -> bool: + last_error = "" + var entry := _get_handle_entry(handle) + if entry.is_empty(): + return _fail_bool("Target handle is stale or belongs to another registry") + if descriptor == null or not descriptor.is_valid(): + return _fail_bool("Target descriptor is invalid") + var previous := entry["descriptor"] as WorldTargetDescriptor + if ( + descriptor.get_target_id() != previous.get_target_id() + or descriptor.get_target_kind() != previous.get_target_kind() + ): + return _fail_bool("Target identity and kind cannot change during an update") + + for capability_id in previous.get_capability_ids(): + _remove_from_index(_target_ids_by_capability, capability_id, previous.get_target_id()) + var isolated_descriptor := descriptor.copy() + entry["descriptor"] = isolated_descriptor + _entries_by_id[isolated_descriptor.get_target_id()] = entry + for capability_id in isolated_descriptor.get_capability_ids(): + _add_to_index(_target_ids_by_capability, capability_id, isolated_descriptor.get_target_id()) + return true + + +func unregister_target(handle: WorldTargetHandle) -> bool: + last_error = "" + var entry := _get_handle_entry(handle) + if entry.is_empty(): + return _fail_bool("Target handle is stale or belongs to another registry") + _erase_entry(handle.get_target_id(), entry) + return true + + +func clear() -> void: + for target_id in _sorted_target_ids.duplicate(): + _erase_entry(target_id, _entries_by_id[target_id]) + last_error = "" + + +func prune_invalid_providers() -> Array[StringName]: + var removed_ids: Array[StringName] = [] + for target_id in _sorted_target_ids.duplicate(): + var entry: Dictionary = _entries_by_id[target_id] + if not bool(entry["has_provider"]): + continue + var provider_ref := entry["provider_ref"] as WeakRef + if provider_ref != null and provider_ref.get_ref() != null: + continue + _erase_entry(target_id, entry) + removed_ids.append(target_id) + return removed_ids + + +func has_target(target_id: StringName) -> bool: + return _entries_by_id.has(target_id) + + +func get_context_id() -> StringName: + return _context_id + + +func size() -> int: + return _entries_by_id.size() + + +func get_descriptor(target_id: StringName) -> WorldTargetDescriptor: + var entry: Dictionary = _entries_by_id.get(target_id, {}) + return entry.get("descriptor") as WorldTargetDescriptor + + +func get_handle(target_id: StringName) -> WorldTargetHandle: + var entry: Dictionary = _entries_by_id.get(target_id, {}) + if entry.is_empty(): + return null + return _make_handle(entry["descriptor"] as WorldTargetDescriptor, int(entry["generation"])) + + +func resolve_handle(handle: WorldTargetHandle) -> WorldTargetDescriptor: + var entry := _get_handle_entry(handle) + return entry.get("descriptor") as WorldTargetDescriptor + + +func is_handle_valid(handle: WorldTargetHandle, require_provider: bool = false) -> bool: + var entry := _get_handle_entry(handle) + if entry.is_empty(): + return false + if not require_provider: + return true + if not bool(entry["has_provider"]): + return false + var provider_ref := entry["provider_ref"] as WeakRef + return provider_ref != null and provider_ref.get_ref() != null + + +func get_provider(handle: WorldTargetHandle) -> Object: + var entry := _get_handle_entry(handle) + if entry.is_empty() or not bool(entry["has_provider"]): + return null + var provider_ref := entry["provider_ref"] as WeakRef + return provider_ref.get_ref() if provider_ref != null else null + + +func get_target_ids( + target_kind: StringName = &"", capability_id: StringName = &"" +) -> Array[StringName]: + var source_ids: Array = _sorted_target_ids + if not capability_id.is_empty(): + source_ids = _target_ids_by_capability.get(capability_id, []) + elif not target_kind.is_empty(): + source_ids = _target_ids_by_kind.get(target_kind, []) + var result: Array[StringName] = [] + for target_id in source_ids: + var descriptor := get_descriptor(target_id) + if descriptor == null: + continue + if not target_kind.is_empty() and descriptor.get_target_kind() != target_kind: + continue + if not capability_id.is_empty() and not descriptor.has_capability(capability_id): + continue + result.append(target_id) + return result + + +func get_descriptors( + target_kind: StringName = &"", capability_id: StringName = &"" +) -> Array[WorldTargetDescriptor]: + var descriptors: Array[WorldTargetDescriptor] = [] + for target_id in get_target_ids(target_kind, capability_id): + descriptors.append(get_descriptor(target_id)) + return descriptors + + +func _get_handle_entry(handle: WorldTargetHandle) -> Dictionary: + if ( + handle == null + or not handle.is_well_formed() + or handle.get_registry_instance_id() != get_instance_id() + or handle.get_context_id() != _context_id + ): + return {} + var entry: Dictionary = _entries_by_id.get(handle.get_target_id(), {}) + if entry.is_empty(): + return {} + var descriptor := entry["descriptor"] as WorldTargetDescriptor + if ( + int(entry["generation"]) != handle.get_generation() + or descriptor.get_target_kind() != handle.get_target_kind() + ): + return {} + return entry + + +func _make_handle(descriptor: WorldTargetDescriptor, generation: int) -> WorldTargetHandle: + return WorldTargetHandle.new( + get_instance_id(), + _context_id, + descriptor.get_target_id(), + descriptor.get_target_kind(), + generation + ) + + +func _erase_entry(target_id: StringName, entry: Dictionary) -> void: + var descriptor := entry["descriptor"] as WorldTargetDescriptor + _sorted_target_ids.erase(target_id) + _remove_from_index(_target_ids_by_kind, descriptor.get_target_kind(), target_id) + for capability_id in descriptor.get_capability_ids(): + _remove_from_index(_target_ids_by_capability, capability_id, target_id) + _entries_by_id.erase(target_id) + + +func _add_to_index(index: Dictionary, index_id: StringName, target_id: StringName) -> void: + var target_ids: Array = index.get(index_id, []) + _insert_sorted_unique(target_ids, target_id) + index[index_id] = target_ids + + +func _remove_from_index(index: Dictionary, index_id: StringName, target_id: StringName) -> void: + var target_ids: Array = index.get(index_id, []) + target_ids.erase(target_id) + if target_ids.is_empty(): + index.erase(index_id) + else: + index[index_id] = target_ids + + +func _insert_sorted_unique(target_ids: Array, target_id: StringName) -> void: + var low := 0 + var high := target_ids.size() + var target_text := String(target_id) + while low < high: + var middle := (low + high) / 2 + var middle_text := String(target_ids[middle]) + if middle_text < target_text: + low = middle + 1 + else: + high = middle + if low < target_ids.size() and String(target_ids[low]) == target_text: + return + target_ids.insert(low, target_id) + + +func _fail_handle(message: String) -> WorldTargetHandle: + last_error = message + return null + + +func _fail_bool(message: String) -> bool: + last_error = message + return false diff --git a/world/targets/WorldTargetRegistry.gd.uid b/world/targets/WorldTargetRegistry.gd.uid new file mode 100644 index 0000000..10f39e6 --- /dev/null +++ b/world/targets/WorldTargetRegistry.gd.uid @@ -0,0 +1 @@ +uid://b6ok1rowe60rg