From dc0f59fcf0c046b57a79b4ef64a18149e23e508f Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 20:51:01 +0200 Subject: [PATCH] feat: route resources through typed content --- .../definitions/CapabilityTagDefinition.gd | 30 ++ .../CapabilityTagDefinition.gd.uid | 1 + simulation/definitions/ContentCatalog.gd | 368 +++++++++++++++++- simulation/definitions/ItemDefinition.gd | 10 + simulation/definitions/PresentationCueIds.gd | 32 ++ .../definitions/PresentationCueIds.gd.uid | 1 + simulation/definitions/ResourceDefinition.gd | 59 +++ .../definitions/ResourceDefinition.gd.uid | 1 + .../definitions/SimulationContentPack.gd | 33 ++ simulation/definitions/StorageDefinition.gd | 57 +++ .../definitions/StorageDefinition.gd.uid | 1 + simulation/definitions/items/food.tres | 2 + simulation/definitions/items/herb.tres | 11 + simulation/definitions/items/wood.tres | 2 + simulation/definitions/packs/core.tres | 19 +- .../definitions/resources/berry_patch.tres | 18 + .../definitions/resources/herb_patch.tres | 19 + simulation/definitions/resources/tree.tres | 18 + .../definitions/storages/apothecary.tres | 13 + simulation/definitions/storages/pantry.tres | 15 + simulation/definitions/storages/woodpile.tres | 14 + simulation/definitions/tags/item_food.tres | 9 + .../definitions/tags/item_medicine.tres | 9 + simulation/definitions/tags/item_wood.tres | 9 + .../tags/resource_harvestable.tres | 9 + .../definitions/tags/storage_village.tres | 9 + simulation/economy/StorageRoutingPolicy.gd | 62 +++ .../economy/StorageRoutingPolicy.gd.uid | 1 + tests/content_catalog_test.gd | 11 +- .../unit/test_content_routing_foundations.gd | 237 +++++++++++ .../test_content_routing_foundations.gd.uid | 1 + 31 files changed, 1071 insertions(+), 10 deletions(-) create mode 100644 simulation/definitions/CapabilityTagDefinition.gd create mode 100644 simulation/definitions/CapabilityTagDefinition.gd.uid create mode 100644 simulation/definitions/PresentationCueIds.gd create mode 100644 simulation/definitions/PresentationCueIds.gd.uid create mode 100644 simulation/definitions/ResourceDefinition.gd create mode 100644 simulation/definitions/ResourceDefinition.gd.uid create mode 100644 simulation/definitions/StorageDefinition.gd create mode 100644 simulation/definitions/StorageDefinition.gd.uid create mode 100644 simulation/definitions/items/herb.tres create mode 100644 simulation/definitions/resources/berry_patch.tres create mode 100644 simulation/definitions/resources/herb_patch.tres create mode 100644 simulation/definitions/resources/tree.tres create mode 100644 simulation/definitions/storages/apothecary.tres create mode 100644 simulation/definitions/storages/pantry.tres create mode 100644 simulation/definitions/storages/woodpile.tres create mode 100644 simulation/definitions/tags/item_food.tres create mode 100644 simulation/definitions/tags/item_medicine.tres create mode 100644 simulation/definitions/tags/item_wood.tres create mode 100644 simulation/definitions/tags/resource_harvestable.tres create mode 100644 simulation/definitions/tags/storage_village.tres create mode 100644 simulation/economy/StorageRoutingPolicy.gd create mode 100644 simulation/economy/StorageRoutingPolicy.gd.uid create mode 100644 tests/unit/test_content_routing_foundations.gd create mode 100644 tests/unit/test_content_routing_foundations.gd.uid diff --git a/simulation/definitions/CapabilityTagDefinition.gd b/simulation/definitions/CapabilityTagDefinition.gd new file mode 100644 index 0000000..111e51c --- /dev/null +++ b/simulation/definitions/CapabilityTagDefinition.gd @@ -0,0 +1,30 @@ +class_name CapabilityTagDefinition +extends Resource + +const DOMAIN_ACTOR := &"actor" +const DOMAIN_TARGET := &"target" +const DOMAIN_ITEM := &"item" +const DOMAIN_RESOURCE := &"resource" +const DOMAIN_STORAGE := &"storage" +const VALID_DOMAINS := [ + DOMAIN_ACTOR, + DOMAIN_TARGET, + DOMAIN_ITEM, + DOMAIN_RESOURCE, + DOMAIN_STORAGE, +] + +@export var tag_id: StringName +@export var display_name: String +@export var domain: StringName + + +func validate() -> Array[String]: + var errors: Array[String] = [] + if tag_id.is_empty(): + errors.append("tag_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for '%s'" % tag_id) + if domain not in VALID_DOMAINS: + errors.append("domain '%s' is invalid for '%s'" % [domain, tag_id]) + return errors diff --git a/simulation/definitions/CapabilityTagDefinition.gd.uid b/simulation/definitions/CapabilityTagDefinition.gd.uid new file mode 100644 index 0000000..60ed294 --- /dev/null +++ b/simulation/definitions/CapabilityTagDefinition.gd.uid @@ -0,0 +1 @@ +uid://dhlqkrwuqdhri diff --git a/simulation/definitions/ContentCatalog.gd b/simulation/definitions/ContentCatalog.gd index d90eaf9..0634cb8 100644 --- a/simulation/definitions/ContentCatalog.gd +++ b/simulation/definitions/ContentCatalog.gd @@ -8,28 +8,39 @@ var _errors: Array[String] = [] var _packs: Array[SimulationContentPack] = [] var _actions: Array[ActionDefinition] = [] var _professions: Array[ProfessionDefinition] = [] +var _capability_tags: Array[CapabilityTagDefinition] = [] var _items: Array[ItemDefinition] = [] +var _resources: Array[ResourceDefinition] = [] +var _storages: Array[StorageDefinition] = [] var _enemies: Array[EnemyDefinition] = [] var _packs_by_id: Dictionary = {} var _actions_by_id: Dictionary = {} var _professions_by_id: Dictionary = {} +var _capability_tags_by_id: Dictionary = {} var _items_by_id: Dictionary = {} +var _resources_by_id: Dictionary = {} +var _storages_by_id: Dictionary = {} var _enemies_by_id: Dictionary = {} -static func create_core(supported_handler_ids: Array[StringName] = []) -> ContentCatalog: +static func create_core( + supported_handler_ids: Array[StringName] = [], + supported_presentation_cue_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) + catalog.rebuild(packs, supported_handler_ids, supported_presentation_cue_ids) return catalog func rebuild( - content_packs: Array[SimulationContentPack], supported_handler_ids: Array[StringName] = [] + content_packs: Array[SimulationContentPack], + supported_handler_ids: Array[StringName] = [], + supported_presentation_cue_ids: Array[StringName] = [] ) -> Array[String]: _clear_published_content() var errors: Array[String] = [] @@ -43,15 +54,34 @@ func rebuild( errors.append("Duplicate supported handler '%s'" % handler_id) else: supported_handlers[handler_id] = true + var cue_ids := ( + supported_presentation_cue_ids + if not supported_presentation_cue_ids.is_empty() + else PresentationCueIds.get_all() + ) + var supported_cues := {} + for cue_id in cue_ids: + if cue_id.is_empty(): + errors.append("Supported presentation cue ID is empty") + elif supported_cues.has(cue_id): + errors.append("Duplicate supported presentation cue '%s'" % cue_id) + else: + supported_cues[cue_id] = true var packs_by_id := {} var actions_by_id := {} var professions_by_id := {} + var capability_tags_by_id := {} var items_by_id := {} + var resources_by_id := {} + var storages_by_id := {} var enemies_by_id := {} var actions: Array[ActionDefinition] = [] var professions: Array[ProfessionDefinition] = [] + var capability_tags: Array[CapabilityTagDefinition] = [] var items: Array[ItemDefinition] = [] + var resources: Array[ResourceDefinition] = [] + var storages: Array[StorageDefinition] = [] var enemies: Array[EnemyDefinition] = [] for pack in pack_list: @@ -66,7 +96,10 @@ func rebuild( packs_by_id[pack.pack_id] = pack _collect_actions(pack, actions, actions_by_id, errors) _collect_professions(pack, professions, professions_by_id, errors) + _collect_capability_tags(pack, capability_tags, capability_tags_by_id, errors) _collect_items(pack, items, items_by_id, errors) + _collect_resources(pack, resources, resources_by_id, errors) + _collect_storages(pack, storages, storages_by_id, errors) _collect_enemies(pack, enemies, enemies_by_id, errors) for pack in pack_list: @@ -93,7 +126,21 @@ func rebuild( ) _validate_action_references( - actions, actions_by_id, professions_by_id, items_by_id, supported_handlers, errors + actions, + actions_by_id, + professions_by_id, + items_by_id, + capability_tags_by_id, + supported_handlers, + supported_cues, + errors + ) + _validate_item_references(items, capability_tags_by_id, supported_cues, errors) + _validate_resource_references( + resources, items_by_id, actions_by_id, capability_tags_by_id, supported_cues, errors + ) + _validate_storage_references( + storages, items_by_id, actions_by_id, capability_tags_by_id, supported_cues, errors ) _validate_enemy_references(enemies, items_by_id, errors) errors.sort() @@ -103,17 +150,26 @@ func rebuild( actions.sort_custom(_sort_actions) professions.sort_custom(_sort_professions) + capability_tags.sort_custom(_sort_capability_tags) items.sort_custom(_sort_items) + resources.sort_custom(_sort_resources) + storages.sort_custom(_sort_storages) enemies.sort_custom(_sort_enemies) _packs = pack_list _actions = actions _professions = professions + _capability_tags = capability_tags _items = items + _resources = resources + _storages = storages _enemies = enemies _packs_by_id = packs_by_id _actions_by_id = actions_by_id _professions_by_id = professions_by_id + _capability_tags_by_id = capability_tags_by_id _items_by_id = items_by_id + _resources_by_id = resources_by_id + _storages_by_id = storages_by_id _enemies_by_id = enemies_by_id _valid = true return [] @@ -139,10 +195,22 @@ func get_professions() -> Array[ProfessionDefinition]: return _professions.duplicate() +func get_capability_tags() -> Array[CapabilityTagDefinition]: + return _capability_tags.duplicate() + + func get_items() -> Array[ItemDefinition]: return _items.duplicate() +func get_resources() -> Array[ResourceDefinition]: + return _resources.duplicate() + + +func get_storages() -> Array[StorageDefinition]: + return _storages.duplicate() + + func get_enemies() -> Array[EnemyDefinition]: return _enemies.duplicate() @@ -159,26 +227,59 @@ func get_profession(profession_id: StringName) -> ProfessionDefinition: return _professions_by_id.get(profession_id) as ProfessionDefinition +func get_capability_tag(tag_id: StringName) -> CapabilityTagDefinition: + return _capability_tags_by_id.get(tag_id) as CapabilityTagDefinition + + func get_item(item_id: StringName) -> ItemDefinition: return _items_by_id.get(item_id) as ItemDefinition +func get_resource(resource_id: StringName) -> ResourceDefinition: + return _resources_by_id.get(resource_id) as ResourceDefinition + + +func get_storage(storage_id: StringName) -> StorageDefinition: + return _storages_by_id.get(storage_id) as StorageDefinition + + func get_enemy(enemy_id: StringName) -> EnemyDefinition: return _enemies_by_id.get(enemy_id) as EnemyDefinition +func get_authoring_report() -> Dictionary: + return { + "valid": _valid, + "errors": get_errors(), + "packs": _ids_for(_packs, &"pack_id"), + "actions": _ids_for(_actions, &"action_id"), + "professions": _ids_for(_professions, &"profession_id"), + "capability_tags": _ids_for(_capability_tags, &"tag_id"), + "items": _ids_for(_items, &"item_id"), + "resources": _ids_for(_resources, &"resource_id"), + "storages": _ids_for(_storages, &"storage_id"), + "enemies": _ids_for(_enemies, &"enemy_id"), + } + + func _clear_published_content() -> void: _valid = false _errors.clear() _packs.clear() _actions.clear() _professions.clear() + _capability_tags.clear() _items.clear() + _resources.clear() + _storages.clear() _enemies.clear() _packs_by_id.clear() _actions_by_id.clear() _professions_by_id.clear() + _capability_tags_by_id.clear() _items_by_id.clear() + _resources_by_id.clear() + _storages_by_id.clear() _enemies_by_id.clear() @@ -230,6 +331,54 @@ static func _collect_items( definitions.append(definition) +static func _collect_capability_tags( + pack: SimulationContentPack, + definitions: Array[CapabilityTagDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.capability_tags: + if definition == null: + continue + if definitions_by_id.has(definition.tag_id): + errors.append("Duplicate tag_id '%s'" % definition.tag_id) + continue + definitions_by_id[definition.tag_id] = definition + definitions.append(definition) + + +static func _collect_resources( + pack: SimulationContentPack, + definitions: Array[ResourceDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.resources: + if definition == null: + continue + if definitions_by_id.has(definition.resource_id): + errors.append("Duplicate resource_id '%s'" % definition.resource_id) + continue + definitions_by_id[definition.resource_id] = definition + definitions.append(definition) + + +static func _collect_storages( + pack: SimulationContentPack, + definitions: Array[StorageDefinition], + definitions_by_id: Dictionary, + errors: Array[String] +) -> void: + for definition in pack.storages: + if definition == null: + continue + if definitions_by_id.has(definition.storage_id): + errors.append("Duplicate storage_id '%s'" % definition.storage_id) + continue + definitions_by_id[definition.storage_id] = definition + definitions.append(definition) + + static func _collect_enemies( pack: SimulationContentPack, definitions: Array[EnemyDefinition], @@ -251,10 +400,32 @@ static func _validate_action_references( actions_by_id: Dictionary, professions_by_id: Dictionary, items_by_id: Dictionary, + capability_tags_by_id: Dictionary, supported_handlers: Dictionary, + supported_cues: Dictionary, errors: Array[String] ) -> void: for definition in actions: + _validate_tag_ids( + "Action '%s' actor" % definition.action_id, + definition.actor_capability_tags, + CapabilityTagDefinition.DOMAIN_ACTOR, + capability_tags_by_id, + errors + ) + _validate_tag_ids( + "Action '%s' target" % definition.action_id, + definition.target_capability_tags, + CapabilityTagDefinition.DOMAIN_TARGET, + capability_tags_by_id, + errors + ) + _validate_presentation_cue( + "Action '%s'" % definition.action_id, + definition.presentation_cue_id, + supported_cues, + errors + ) for handler_field in [ ["selection policy", definition.selection_policy_id], ["target policy", definition.target_policy_id], @@ -309,6 +480,174 @@ static func _validate_action_references( % [definition.action_id, definition.completion_cost_resource_id] ) ) + for effect in definition.effects: + if effect == null: + continue + var effect_item_id: StringName = effect.get("item_id") + if not effect_item_id.is_empty() and not items_by_id.has(effect_item_id): + errors.append( + ( + "Action '%s' effect references unknown item '%s'" + % [definition.action_id, effect_item_id] + ) + ) + var scheduled_action_id: StringName = effect.get("scheduled_action_id") + if not scheduled_action_id.is_empty() and not actions_by_id.has(scheduled_action_id): + errors.append( + ( + "Action '%s' effect references unknown action '%s'" + % [definition.action_id, scheduled_action_id] + ) + ) + + +static func _validate_item_references( + items: Array[ItemDefinition], + capability_tags_by_id: Dictionary, + supported_cues: Dictionary, + errors: Array[String] +) -> void: + for definition in items: + _validate_tag_ids( + "Item '%s'" % definition.item_id, + definition.capability_tag_ids, + CapabilityTagDefinition.DOMAIN_ITEM, + capability_tags_by_id, + errors + ) + _validate_presentation_cue( + "Item '%s'" % definition.item_id, definition.presentation_cue_id, supported_cues, errors + ) + + +static func _validate_resource_references( + resources: Array[ResourceDefinition], + items_by_id: Dictionary, + actions_by_id: Dictionary, + capability_tags_by_id: Dictionary, + supported_cues: Dictionary, + errors: Array[String] +) -> void: + for definition in resources: + var item := items_by_id.get(definition.item_id) as ItemDefinition + if item == null: + errors.append( + ( + "Resource '%s' references unknown item '%s'" + % [definition.resource_id, definition.item_id] + ) + ) + elif not item.is_resource(): + errors.append( + ( + "Resource '%s' references non-resource item '%s'" + % [definition.resource_id, definition.item_id] + ) + ) + var action := actions_by_id.get(definition.extraction_action_id) as ActionDefinition + if action == null: + errors.append( + ( + "Resource '%s' references unknown action '%s'" + % [definition.resource_id, definition.extraction_action_id] + ) + ) + elif action.target_type != SimulationIds.TARGET_RESOURCE: + errors.append( + ( + "Resource '%s' references non-resource-target action '%s'" + % [definition.resource_id, definition.extraction_action_id] + ) + ) + _validate_tag_ids( + "Resource '%s'" % definition.resource_id, + definition.capability_tag_ids, + CapabilityTagDefinition.DOMAIN_RESOURCE, + capability_tags_by_id, + errors + ) + _validate_presentation_cue( + "Resource '%s'" % definition.resource_id, + definition.presentation_cue_id, + supported_cues, + errors + ) + + +static func _validate_storage_references( + storages: Array[StorageDefinition], + items_by_id: Dictionary, + actions_by_id: Dictionary, + capability_tags_by_id: Dictionary, + supported_cues: Dictionary, + errors: Array[String] +) -> void: + for definition in storages: + for item_id in definition.accepted_item_ids: + var item := items_by_id.get(item_id) as ItemDefinition + if item == null: + errors.append( + "Storage '%s' references unknown item '%s'" % [definition.storage_id, item_id] + ) + elif not item.is_resource(): + errors.append( + "Storage '%s' accepts non-resource item '%s'" % [definition.storage_id, item_id] + ) + _validate_tag_ids( + "Storage '%s' accepted item" % definition.storage_id, + definition.accepted_item_tag_ids, + CapabilityTagDefinition.DOMAIN_ITEM, + capability_tags_by_id, + errors + ) + _validate_tag_ids( + "Storage '%s'" % definition.storage_id, + definition.capability_tag_ids, + CapabilityTagDefinition.DOMAIN_STORAGE, + capability_tags_by_id, + errors + ) + for action_id in [definition.deposit_action_id, definition.withdrawal_action_id]: + if not action_id.is_empty() and not actions_by_id.has(action_id): + errors.append( + ( + "Storage '%s' references unknown action '%s'" + % [definition.storage_id, action_id] + ) + ) + _validate_presentation_cue( + "Storage '%s'" % definition.storage_id, + definition.presentation_cue_id, + supported_cues, + errors + ) + + +static func _validate_tag_ids( + owner: String, + tag_ids: Array[StringName], + expected_domain: StringName, + capability_tags_by_id: Dictionary, + errors: Array[String] +) -> void: + for tag_id in tag_ids: + var tag := capability_tags_by_id.get(tag_id) as CapabilityTagDefinition + if tag == null: + errors.append("%s references unknown tag '%s'" % [owner, tag_id]) + elif tag.domain != expected_domain: + errors.append( + ( + "%s references '%s' tag '%s' where '%s' is required" + % [owner, tag.domain, tag_id, expected_domain] + ) + ) + + +static func _validate_presentation_cue( + owner: String, cue_id: StringName, supported_cues: Dictionary, errors: Array[String] +) -> void: + if not cue_id.is_empty() and not supported_cues.has(cue_id): + errors.append("%s references unknown presentation cue '%s'" % [owner, cue_id]) static func _validate_enemy_references( @@ -348,9 +687,30 @@ static func _sort_professions(first: ProfessionDefinition, second: ProfessionDef return String(first.profession_id) < String(second.profession_id) +static func _sort_capability_tags( + first: CapabilityTagDefinition, second: CapabilityTagDefinition +) -> bool: + return String(first.tag_id) < String(second.tag_id) + + static func _sort_items(first: ItemDefinition, second: ItemDefinition) -> bool: return String(first.item_id) < String(second.item_id) +static func _sort_resources(first: ResourceDefinition, second: ResourceDefinition) -> bool: + return String(first.resource_id) < String(second.resource_id) + + +static func _sort_storages(first: StorageDefinition, second: StorageDefinition) -> bool: + return String(first.storage_id) < String(second.storage_id) + + static func _sort_enemies(first: EnemyDefinition, second: EnemyDefinition) -> bool: return String(first.enemy_id) < String(second.enemy_id) + + +static func _ids_for(definitions: Array, id_property: StringName) -> Array[StringName]: + var ids: Array[StringName] = [] + for definition in definitions: + ids.append(StringName(definition.get(id_property))) + return ids diff --git a/simulation/definitions/ItemDefinition.gd b/simulation/definitions/ItemDefinition.gd index 549099f..2f44758 100644 --- a/simulation/definitions/ItemDefinition.gd +++ b/simulation/definitions/ItemDefinition.gd @@ -8,6 +8,8 @@ const VALID_CATEGORIES := [CATEGORY_RESOURCE, CATEGORY_WEAPON] @export var item_id: StringName @export var display_name: String @export var category: StringName = CATEGORY_RESOURCE +@export var capability_tag_ids: Array[StringName] = [] +@export var presentation_cue_id: StringName @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 := 0.0 @@ -35,6 +37,14 @@ func validate() -> Array[String]: 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]) + var seen_tags := {} + for tag_id in capability_tag_ids: + if tag_id.is_empty(): + errors.append("capability tag ID is empty for '%s'" % item_id) + elif seen_tags.has(tag_id): + errors.append("duplicate capability tag '%s' for '%s'" % [tag_id, item_id]) + else: + seen_tags[tag_id] = true return errors diff --git a/simulation/definitions/PresentationCueIds.gd b/simulation/definitions/PresentationCueIds.gd new file mode 100644 index 0000000..09b62e1 --- /dev/null +++ b/simulation/definitions/PresentationCueIds.gd @@ -0,0 +1,32 @@ +class_name PresentationCueIds +extends RefCounted + +# Pure stable vocabulary only. Presentation layers may map these IDs to scenes, +# audio, particles, or animation without making the headless catalog load them. +const ITEM_FOOD := &"item.food" +const ITEM_HERBS := &"item.herbs" +const ITEM_WOOD := &"item.wood" +const RESOURCE_BERRY_PATCH := &"resource.berry_patch" +const RESOURCE_HERB_PATCH := &"resource.herb_patch" +const RESOURCE_TREE := &"resource.tree" +const STORAGE_APOTHECARY := &"storage.apothecary" +const STORAGE_PANTRY := &"storage.pantry" +const STORAGE_WOODPILE := &"storage.woodpile" + + +static func get_all() -> Array[StringName]: + var cue_ids: Array[StringName] = [ + ITEM_FOOD, + ITEM_HERBS, + ITEM_WOOD, + RESOURCE_BERRY_PATCH, + RESOURCE_HERB_PATCH, + RESOURCE_TREE, + STORAGE_APOTHECARY, + STORAGE_PANTRY, + STORAGE_WOODPILE, + ] + cue_ids.sort_custom( + func(first: StringName, second: StringName): return String(first) < String(second) + ) + return cue_ids diff --git a/simulation/definitions/PresentationCueIds.gd.uid b/simulation/definitions/PresentationCueIds.gd.uid new file mode 100644 index 0000000..9c80aae --- /dev/null +++ b/simulation/definitions/PresentationCueIds.gd.uid @@ -0,0 +1 @@ +uid://c0x5hj6mps7cr diff --git a/simulation/definitions/ResourceDefinition.gd b/simulation/definitions/ResourceDefinition.gd new file mode 100644 index 0000000..1ba7db1 --- /dev/null +++ b/simulation/definitions/ResourceDefinition.gd @@ -0,0 +1,59 @@ +class_name ResourceDefinition +extends Resource + +@export var resource_id: StringName +@export var display_name: String +@export var item_id: StringName +@export var extraction_action_id: StringName +@export var capability_tag_ids: Array[StringName] = [] +@export var presentation_cue_id: StringName +@export_range(0.0, 100000.0, 0.1) var initial_amount := 10.0 +@export_range(0.0, 100000.0, 0.1) var yield_per_action := 1.0 +@export_range(0.0, 100000.0, 0.1) var max_amount := 10.0 +@export_range(0.0, 1000.0, 0.01) var regrow_rate := 0.0 +@export_range(0.0, 1.0, 0.05) var safety_risk := 0.0 +@export_range(0.0, 1000.0, 0.1) var comfort_distance := 18.0 +@export var discovery_priority := 0.0 +@export var can_npcs_use := true +@export var can_player_use := true + + +func validate() -> Array[String]: + var errors: Array[String] = [] + if resource_id.is_empty(): + errors.append("resource_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for '%s'" % resource_id) + if item_id.is_empty(): + errors.append("item_id is empty for '%s'" % resource_id) + if extraction_action_id.is_empty(): + errors.append("extraction_action_id is empty for '%s'" % resource_id) + if not is_finite(initial_amount) or initial_amount <= 0.0: + errors.append("initial_amount must be positive for '%s'" % resource_id) + if not is_finite(yield_per_action) or yield_per_action <= 0.0: + errors.append("yield_per_action must be positive for '%s'" % resource_id) + if not is_finite(max_amount) or max_amount < initial_amount: + errors.append("max_amount must cover initial_amount for '%s'" % resource_id) + if not is_finite(regrow_rate) or regrow_rate < 0.0: + errors.append("regrow_rate must be non-negative for '%s'" % resource_id) + if not is_finite(safety_risk) or safety_risk < 0.0 or safety_risk > 1.0: + errors.append("safety_risk must be between zero and one for '%s'" % resource_id) + if not is_finite(comfort_distance) or comfort_distance < 0.0: + errors.append("comfort_distance must be non-negative for '%s'" % resource_id) + if not is_finite(discovery_priority): + errors.append("discovery_priority must be finite for '%s'" % resource_id) + _validate_unique_ids(capability_tag_ids, "capability tag", errors) + return errors + + +static func _validate_unique_ids( + ids: Array[StringName], label: String, errors: Array[String] +) -> void: + var seen := {} + for value in ids: + if value.is_empty(): + errors.append("%s ID is empty" % label) + elif seen.has(value): + errors.append("duplicate %s '%s'" % [label, value]) + else: + seen[value] = true diff --git a/simulation/definitions/ResourceDefinition.gd.uid b/simulation/definitions/ResourceDefinition.gd.uid new file mode 100644 index 0000000..98bc19a --- /dev/null +++ b/simulation/definitions/ResourceDefinition.gd.uid @@ -0,0 +1 @@ +uid://m77tgpn1wpos diff --git a/simulation/definitions/SimulationContentPack.gd b/simulation/definitions/SimulationContentPack.gd index 4ab54e5..9b700aa 100644 --- a/simulation/definitions/SimulationContentPack.gd +++ b/simulation/definitions/SimulationContentPack.gd @@ -7,7 +7,10 @@ extends Resource @export var required_handler_ids: Array[StringName] = [] @export var actions: Array[ActionDefinition] = [] @export var professions: Array[ProfessionDefinition] = [] +@export var capability_tags: Array[CapabilityTagDefinition] = [] @export var items: Array[ItemDefinition] = [] +@export var resources: Array[ResourceDefinition] = [] +@export var storages: Array[StorageDefinition] = [] @export var enemies: Array[EnemyDefinition] = [] @@ -23,7 +26,10 @@ func validate() -> Array[String]: errors.append("pack '%s' cannot require itself" % pack_id) _validate_actions(errors) _validate_professions(errors) + _validate_capability_tags(errors) _validate_items(errors) + _validate_resources(errors) + _validate_storages(errors) _validate_enemies(errors) return errors @@ -55,6 +61,33 @@ func _validate_items(errors: Array[String]) -> void: errors.append("ItemDefinition: " + error) +func _validate_capability_tags(errors: Array[String]) -> void: + for definition in capability_tags: + if definition == null: + errors.append("capability_tags contains a null definition") + continue + for error in definition.validate(): + errors.append("CapabilityTagDefinition: " + error) + + +func _validate_resources(errors: Array[String]) -> void: + for definition in resources: + if definition == null: + errors.append("resources contains a null definition") + continue + for error in definition.validate(): + errors.append("ResourceDefinition: " + error) + + +func _validate_storages(errors: Array[String]) -> void: + for definition in storages: + if definition == null: + errors.append("storages contains a null definition") + continue + for error in definition.validate(): + errors.append("StorageDefinition: " + error) + + func _validate_enemies(errors: Array[String]) -> void: for definition in enemies: if definition == null: diff --git a/simulation/definitions/StorageDefinition.gd b/simulation/definitions/StorageDefinition.gd new file mode 100644 index 0000000..78ed257 --- /dev/null +++ b/simulation/definitions/StorageDefinition.gd @@ -0,0 +1,57 @@ +class_name StorageDefinition +extends Resource + +@export var storage_id: StringName +@export var display_name: String +@export_range(0.0, 1000000.0, 0.1) var capacity := StorageStateRecord.DEFAULT_CAPACITY +@export var accepted_item_ids: Array[StringName] = [] +@export var accepted_item_tag_ids: Array[StringName] = [] +@export var capability_tag_ids: Array[StringName] = [] +@export var routing_priority := 0 +@export var deposit_action_id: StringName +@export var withdrawal_action_id: StringName +@export var presentation_cue_id: StringName + + +func validate() -> Array[String]: + var errors: Array[String] = [] + if storage_id.is_empty(): + errors.append("storage_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for '%s'" % storage_id) + if not is_finite(capacity) or capacity <= 0.0: + errors.append("capacity must be positive for '%s'" % storage_id) + if accepted_item_ids.is_empty() and accepted_item_tag_ids.is_empty(): + errors.append("storage '%s' must accept an item or item tag" % storage_id) + _validate_unique_ids(accepted_item_ids, "accepted item", errors) + _validate_unique_ids(accepted_item_tag_ids, "accepted item tag", errors) + _validate_unique_ids(capability_tag_ids, "capability tag", errors) + return errors + + +func accepts_item(item: ItemDefinition) -> bool: + if item == null: + return false + if item.item_id in accepted_item_ids: + return true + for tag_id in item.capability_tag_ids: + if tag_id in accepted_item_tag_ids: + return true + return false + + +func create_state(initial_amounts: Dictionary = {}) -> StorageStateRecord: + return StorageStateRecord.create(storage_id, initial_amounts, capacity) + + +static func _validate_unique_ids( + ids: Array[StringName], label: String, errors: Array[String] +) -> void: + var seen := {} + for value in ids: + if value.is_empty(): + errors.append("%s ID is empty" % label) + elif seen.has(value): + errors.append("duplicate %s '%s'" % [label, value]) + else: + seen[value] = true diff --git a/simulation/definitions/StorageDefinition.gd.uid b/simulation/definitions/StorageDefinition.gd.uid new file mode 100644 index 0000000..aa453c8 --- /dev/null +++ b/simulation/definitions/StorageDefinition.gd.uid @@ -0,0 +1 @@ +uid://doe6owhpwok2e diff --git a/simulation/definitions/items/food.tres b/simulation/definitions/items/food.tres index e746b1e..b54db26 100644 --- a/simulation/definitions/items/food.tres +++ b/simulation/definitions/items/food.tres @@ -7,3 +7,5 @@ script = ExtResource("1") item_id = &"food" display_name = "Food" category = &"resource" +capability_tag_ids = Array[StringName]([&"item.food"]) +presentation_cue_id = &"item.food" diff --git a/simulation/definitions/items/herb.tres b/simulation/definitions/items/herb.tres new file mode 100644 index 0000000..77b98fa --- /dev/null +++ b/simulation/definitions/items/herb.tres @@ -0,0 +1,11 @@ +[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 = &"herb" +display_name = "Medicinal Herb" +category = &"resource" +capability_tag_ids = Array[StringName]([&"item.medicine"]) +presentation_cue_id = &"item.herbs" diff --git a/simulation/definitions/items/wood.tres b/simulation/definitions/items/wood.tres index e7a2bc6..7967771 100644 --- a/simulation/definitions/items/wood.tres +++ b/simulation/definitions/items/wood.tres @@ -7,3 +7,5 @@ script = ExtResource("1") item_id = &"wood" display_name = "Wood" category = &"resource" +capability_tag_ids = Array[StringName]([&"item.wood"]) +presentation_cue_id = &"item.wood" diff --git a/simulation/definitions/packs/core.tres b/simulation/definitions/packs/core.tres index 29b3756..2722f24 100644 --- a/simulation/definitions/packs/core.tres +++ b/simulation/definitions/packs/core.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=28 format=3] +[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=40 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"] @@ -26,6 +26,18 @@ [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"] [ext_resource type="Resource" path="res://simulation/definitions/enemies/boar.tres" id="26_boar"] +[ext_resource type="Resource" path="res://simulation/definitions/items/herb.tres" id="27_herb"] +[ext_resource type="Resource" path="res://simulation/definitions/tags/item_food.tres" id="28_item_food"] +[ext_resource type="Resource" path="res://simulation/definitions/tags/item_medicine.tres" id="29_item_medicine"] +[ext_resource type="Resource" path="res://simulation/definitions/tags/item_wood.tres" id="30_item_wood"] +[ext_resource type="Resource" path="res://simulation/definitions/tags/resource_harvestable.tres" id="31_resource_harvestable"] +[ext_resource type="Resource" path="res://simulation/definitions/tags/storage_village.tres" id="32_storage_village"] +[ext_resource type="Resource" path="res://simulation/definitions/resources/berry_patch.tres" id="33_berry_patch"] +[ext_resource type="Resource" path="res://simulation/definitions/resources/herb_patch.tres" id="34_herb_patch"] +[ext_resource type="Resource" path="res://simulation/definitions/resources/tree.tres" id="35_tree"] +[ext_resource type="Resource" path="res://simulation/definitions/storages/apothecary.tres" id="36_apothecary"] +[ext_resource type="Resource" path="res://simulation/definitions/storages/pantry.tres" id="37_pantry"] +[ext_resource type="Resource" path="res://simulation/definitions/storages/woodpile.tres" id="38_woodpile"] [resource] script = ExtResource("1_pack") @@ -33,5 +45,8 @@ 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")] +capability_tags = [ExtResource("28_item_food"), ExtResource("29_item_medicine"), ExtResource("30_item_wood"), ExtResource("31_resource_harvestable"), ExtResource("32_storage_village")] +items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood"), ExtResource("27_herb")] +resources = [ExtResource("33_berry_patch"), ExtResource("34_herb_patch"), ExtResource("35_tree")] +storages = [ExtResource("36_apothecary"), ExtResource("37_pantry"), ExtResource("38_woodpile")] enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")] diff --git a/simulation/definitions/resources/berry_patch.tres b/simulation/definitions/resources/berry_patch.tres new file mode 100644 index 0000000..06c7a58 --- /dev/null +++ b/simulation/definitions/resources/berry_patch.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ResourceDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +resource_id = &"resource_berry_patch" +display_name = "Berry Patch" +item_id = &"food" +extraction_action_id = &"gather_food" +capability_tag_ids = Array[StringName]([&"resource.harvestable"]) +presentation_cue_id = &"resource.berry_patch" +initial_amount = 10.0 +yield_per_action = 2.0 +max_amount = 10.0 +safety_risk = 0.05 +comfort_distance = 16.0 +discovery_priority = 1.0 diff --git a/simulation/definitions/resources/herb_patch.tres b/simulation/definitions/resources/herb_patch.tres new file mode 100644 index 0000000..4f8775c --- /dev/null +++ b/simulation/definitions/resources/herb_patch.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ResourceDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +resource_id = &"resource_herb_patch" +display_name = "Medicinal Herb Patch" +item_id = &"herb" +extraction_action_id = &"gather_food" +capability_tag_ids = Array[StringName]([&"resource.harvestable"]) +presentation_cue_id = &"resource.herb_patch" +initial_amount = 8.0 +yield_per_action = 1.0 +max_amount = 8.0 +regrow_rate = 0.01 +safety_risk = 0.15 +comfort_distance = 14.0 +discovery_priority = 0.5 diff --git a/simulation/definitions/resources/tree.tres b/simulation/definitions/resources/tree.tres new file mode 100644 index 0000000..d60b711 --- /dev/null +++ b/simulation/definitions/resources/tree.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/ResourceDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +resource_id = &"resource_tree" +display_name = "Tree" +item_id = &"wood" +extraction_action_id = &"gather_wood" +capability_tag_ids = Array[StringName]([&"resource.harvestable"]) +presentation_cue_id = &"resource.tree" +initial_amount = 12.0 +yield_per_action = 3.0 +max_amount = 12.0 +safety_risk = 0.1 +comfort_distance = 20.0 +discovery_priority = 1.0 diff --git a/simulation/definitions/storages/apothecary.tres b/simulation/definitions/storages/apothecary.tres new file mode 100644 index 0000000..48f1c21 --- /dev/null +++ b/simulation/definitions/storages/apothecary.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="StorageDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/StorageDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +storage_id = &"village_apothecary" +display_name = "Village Apothecary" +capacity = 40.0 +accepted_item_tag_ids = Array[StringName]([&"item.medicine"]) +capability_tag_ids = Array[StringName]([&"storage.village"]) +routing_priority = 100 +presentation_cue_id = &"storage.apothecary" diff --git a/simulation/definitions/storages/pantry.tres b/simulation/definitions/storages/pantry.tres new file mode 100644 index 0000000..e32c648 --- /dev/null +++ b/simulation/definitions/storages/pantry.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="StorageDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/StorageDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +storage_id = &"village_pantry" +display_name = "Village Pantry" +capacity = 100.0 +accepted_item_tag_ids = Array[StringName]([&"item.food"]) +capability_tag_ids = Array[StringName]([&"storage.village"]) +routing_priority = 100 +deposit_action_id = &"deposit_food" +withdrawal_action_id = &"withdraw_food" +presentation_cue_id = &"storage.pantry" diff --git a/simulation/definitions/storages/woodpile.tres b/simulation/definitions/storages/woodpile.tres new file mode 100644 index 0000000..e22fe59 --- /dev/null +++ b/simulation/definitions/storages/woodpile.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="StorageDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/StorageDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +storage_id = &"village_woodpile" +display_name = "Village Woodpile" +capacity = 100.0 +accepted_item_tag_ids = Array[StringName]([&"item.wood"]) +capability_tag_ids = Array[StringName]([&"storage.village"]) +routing_priority = 100 +deposit_action_id = &"deposit_wood" +presentation_cue_id = &"storage.woodpile" diff --git a/simulation/definitions/tags/item_food.tres b/simulation/definitions/tags/item_food.tres new file mode 100644 index 0000000..7138c58 --- /dev/null +++ b/simulation/definitions/tags/item_food.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CapabilityTagDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/CapabilityTagDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +tag_id = &"item.food" +display_name = "Food" +domain = &"item" diff --git a/simulation/definitions/tags/item_medicine.tres b/simulation/definitions/tags/item_medicine.tres new file mode 100644 index 0000000..5d0fb45 --- /dev/null +++ b/simulation/definitions/tags/item_medicine.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CapabilityTagDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/CapabilityTagDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +tag_id = &"item.medicine" +display_name = "Medicine" +domain = &"item" diff --git a/simulation/definitions/tags/item_wood.tres b/simulation/definitions/tags/item_wood.tres new file mode 100644 index 0000000..c9f2211 --- /dev/null +++ b/simulation/definitions/tags/item_wood.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CapabilityTagDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/CapabilityTagDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +tag_id = &"item.wood" +display_name = "Wood" +domain = &"item" diff --git a/simulation/definitions/tags/resource_harvestable.tres b/simulation/definitions/tags/resource_harvestable.tres new file mode 100644 index 0000000..0b475ba --- /dev/null +++ b/simulation/definitions/tags/resource_harvestable.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CapabilityTagDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/CapabilityTagDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +tag_id = &"resource.harvestable" +display_name = "Harvestable" +domain = &"resource" diff --git a/simulation/definitions/tags/storage_village.tres b/simulation/definitions/tags/storage_village.tres new file mode 100644 index 0000000..c3438ca --- /dev/null +++ b/simulation/definitions/tags/storage_village.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="CapabilityTagDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/CapabilityTagDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +tag_id = &"storage.village" +display_name = "Village Storage" +domain = &"storage" diff --git a/simulation/economy/StorageRoutingPolicy.gd b/simulation/economy/StorageRoutingPolicy.gd new file mode 100644 index 0000000..7d9277a --- /dev/null +++ b/simulation/economy/StorageRoutingPolicy.gd @@ -0,0 +1,62 @@ +class_name StorageRoutingPolicy +extends RefCounted + +var catalog: ContentCatalog + + +func _init(content_catalog: ContentCatalog = null) -> void: + catalog = content_catalog + + +func resolve_definition( + item_id: StringName, available_storage_ids: Array[StringName] = [] +) -> StorageDefinition: + var candidates := get_candidates(item_id, available_storage_ids) + return candidates[0] if not candidates.is_empty() else null + + +func resolve_state( + item_id: StringName, requested_amount: float, states: Array[StorageStateRecord] +) -> StorageStateRecord: + if not is_finite(requested_amount) or requested_amount <= 0.0: + return null + var states_by_id := {} + var available_ids: Array[StringName] = [] + for state in states: + if state == null or state.get_available_capacity() < requested_amount: + continue + states_by_id[state.get_storage_id()] = state + available_ids.append(state.get_storage_id()) + var definition := resolve_definition(item_id, available_ids) + return ( + states_by_id.get(definition.storage_id) as StorageStateRecord + if definition != null + else null + ) + + +func get_candidates( + item_id: StringName, available_storage_ids: Array[StringName] = [] +) -> Array[StorageDefinition]: + var candidates: Array[StorageDefinition] = [] + if catalog == null or not catalog.is_valid(): + return candidates + var item := catalog.get_item(item_id) + if item == null: + return candidates + var available := {} + for storage_id in available_storage_ids: + available[storage_id] = true + for definition in catalog.get_storages(): + if not available.is_empty() and not available.has(definition.storage_id): + continue + if definition.accepts_item(item): + candidates.append(definition) + candidates.sort_custom(_is_preferred_route) + return candidates + + +static func _is_preferred_route(first: StorageDefinition, second: StorageDefinition) -> bool: + if first.routing_priority != second.routing_priority: + return first.routing_priority > second.routing_priority + return String(first.storage_id) < String(second.storage_id) diff --git a/simulation/economy/StorageRoutingPolicy.gd.uid b/simulation/economy/StorageRoutingPolicy.gd.uid new file mode 100644 index 0000000..f9dcfaf --- /dev/null +++ b/simulation/economy/StorageRoutingPolicy.gd.uid @@ -0,0 +1 @@ +uid://cpmen6gvxr64i diff --git a/tests/content_catalog_test.gd b/tests/content_catalog_test.gd index cb05873..0d14138 100644 --- a/tests/content_catalog_test.gd +++ b/tests/content_catalog_test.gd @@ -47,8 +47,8 @@ func _test_core_pack() -> void: "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" + _item_ids(catalog.get_items()) == [&"food", &"herb", &"item_claw", &"item_sword", &"wood"], + "Core items should include legacy items and herbs in stable-ID order" ) _check( _enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"], @@ -96,13 +96,16 @@ func _test_deterministic_enumeration_and_dependencies() -> void: "Pack enumeration should not depend on input order" ) _check( - _item_ids(catalog.get_items()) == [&"amber", &"food", &"item_claw", &"item_sword", &"wood"], + ( + _item_ids(catalog.get_items()) + == [&"amber", &"food", &"herb", &"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, + catalog.get_items().size() == 6, "Callers should not be able to mutate the catalog's ordered arrays" ) diff --git a/tests/unit/test_content_routing_foundations.gd b/tests/unit/test_content_routing_foundations.gd new file mode 100644 index 0000000..b853e37 --- /dev/null +++ b/tests/unit/test_content_routing_foundations.gd @@ -0,0 +1,237 @@ +extends GutTest + + +func test_core_catalog_enumerates_new_authoring_domains_deterministically() -> void: + var catalog := ContentCatalog.create_core() + assert_true(catalog.is_valid(), "%s" % [catalog.get_errors()]) + assert_eq( + _tag_ids(catalog.get_capability_tags()), + [&"item.food", &"item.medicine", &"item.wood", &"resource.harvestable", &"storage.village"] + ) + assert_eq( + _resource_ids(catalog.get_resources()), + [&"resource_berry_patch", &"resource_herb_patch", &"resource_tree"] + ) + assert_eq( + _storage_ids(catalog.get_storages()), + [&"village_apothecary", &"village_pantry", &"village_woodpile"] + ) + var report := catalog.get_authoring_report() + assert_true(report["valid"]) + assert_eq(report["resources"], _resource_ids(catalog.get_resources())) + assert_eq(report["storages"], _storage_ids(catalog.get_storages())) + assert_false(report.has("presentation_assets")) + var returned_resources := catalog.get_resources() + returned_resources.clear() + assert_eq(catalog.get_resources().size(), 3) + + +func test_herb_route_is_data_only_and_legacy_routes_remain_stable() -> void: + var catalog := ContentCatalog.create_core() + var routing := StorageRoutingPolicy.new(catalog) + var herb_source := catalog.get_resource(&"resource_herb_patch") + assert_not_null(herb_source) + assert_eq(herb_source.item_id, &"herb") + assert_eq(herb_source.extraction_action_id, SimulationIds.ACTION_GATHER_FOOD) + assert_eq(routing.resolve_definition(herb_source.item_id).storage_id, &"village_apothecary") + assert_eq( + routing.resolve_definition(SimulationIds.RESOURCE_FOOD).storage_id, + SimulationIds.STORAGE_VILLAGE_PANTRY + ) + assert_eq( + routing.resolve_definition(SimulationIds.RESOURCE_WOOD).storage_id, + SimulationIds.STORAGE_VILLAGE_WOODPILE + ) + assert_null(routing.resolve_definition(&"unknown_item")) + + var apothecary := catalog.get_storage(&"village_apothecary") + var nearly_full := apothecary.create_state({"herb": 39.0}) + var states: Array[StorageStateRecord] = [nearly_full] + assert_same(routing.resolve_state(&"herb", 1.0, states), nearly_full) + assert_null(routing.resolve_state(&"herb", 2.0, states)) + + +func test_routes_rank_priority_then_stable_storage_id() -> void: + var core := load(ContentCatalog.CORE_PACK_PATH) as SimulationContentPack + var addon := _pack(&"routing_addon") + addon.required_pack_ids = [&"core"] + var early := _storage(&"a_medicine_store", &"item.medicine", 100) + var low_priority := _storage(&"field_medicine_store", &"item.medicine", 10) + addon.storages = [low_priority, early] + var packs: Array[SimulationContentPack] = [addon, core] + var catalog := ContentCatalog.new() + assert_true(catalog.rebuild(packs).is_empty(), "%s" % [catalog.get_errors()]) + + var candidates := StorageRoutingPolicy.new(catalog).get_candidates(&"herb") + assert_eq( + _storage_ids(candidates), + [&"a_medicine_store", &"village_apothecary", &"field_medicine_store"] + ) + + +func test_catalog_rejects_duplicate_content_ids_without_publishing_overrides() -> void: + var first := _complete_minimal_pack(&"first") + var second := _complete_minimal_pack(&"second") + var packs: Array[SimulationContentPack] = [second, first] + var catalog := ContentCatalog.new() + var duplicated_cues: Array[StringName] = [&"known.cue", &"known.cue"] + var errors := catalog.rebuild(packs, [], duplicated_cues) + + for expected in [ + "Duplicate action_id 'shared_action'", + "Duplicate item_id 'shared_item'", + "Duplicate tag_id 'shared_tag'", + "Duplicate resource_id 'shared_resource'", + "Duplicate storage_id 'shared_storage'", + "Duplicate supported presentation cue 'known.cue'", + ]: + assert_true(_has_error(errors, expected), "%s missing from %s" % [expected, errors]) + assert_false(catalog.is_valid()) + assert_true(catalog.get_items().is_empty()) + assert_null(catalog.get_storage(&"shared_storage")) + + +func test_catalog_rejects_unknown_action_item_tag_handler_and_cue_references() -> void: + var pack := _pack(&"broken") + var item := _item(&"broken_item", &"unknown_item_tag") + item.presentation_cue_id = &"unknown.item.cue" + var action := _action(&"broken_action") + action.actor_capability_tags = [&"unknown_actor_tag"] + action.target_capability_tags = [&"unknown_target_tag"] + action.completion_handler_id = &"unknown_handler" + action.presentation_cue_id = &"unknown.action.cue" + var effect := ActionEffect.new() + effect.effect_id = &"broken_effect" + effect.effect_kind = ActionEffect.KIND_SCHEDULE_ACTION + effect.scheduled_action_id = &"unknown_scheduled_action" + effect.item_id = &"unknown_effect_item" + action.effects = [effect] + var resource := _resource(&"broken_resource", &"unknown_resource_item", &"unknown_action") + resource.capability_tag_ids = [&"unknown_resource_tag"] + resource.presentation_cue_id = &"unknown.resource.cue" + var storage := _storage(&"broken_storage", &"unknown_storage_item_tag", 1) + storage.accepted_item_ids = [&"unknown_storage_item"] + storage.capability_tag_ids = [&"unknown_storage_tag"] + storage.deposit_action_id = &"unknown_deposit_action" + storage.presentation_cue_id = &"unknown.storage.cue" + pack.items = [item] + pack.actions = [action] + pack.resources = [resource] + pack.storages = [storage] + var packs: Array[SimulationContentPack] = [pack] + var handlers: Array[StringName] = [&"known_handler"] + var cues: Array[StringName] = [&"known.cue"] + var errors := ContentCatalog.new().rebuild(packs, handlers, cues) + + for expected in [ + "unknown tag 'unknown_item_tag'", + "unknown presentation cue 'unknown.item.cue'", + "unknown tag 'unknown_actor_tag'", + "unknown tag 'unknown_target_tag'", + "unknown completion handler 'unknown_handler'", + "unknown action 'unknown_scheduled_action'", + "unknown item 'unknown_effect_item'", + "unknown item 'unknown_resource_item'", + "unknown action 'unknown_action'", + "unknown tag 'unknown_resource_tag'", + "unknown item 'unknown_storage_item'", + "unknown tag 'unknown_storage_item_tag'", + "unknown tag 'unknown_storage_tag'", + "unknown action 'unknown_deposit_action'", + "unknown presentation cue 'unknown.storage.cue'", + ]: + assert_true(_has_error(errors, expected), "%s missing from %s" % [expected, errors]) + + +func _complete_minimal_pack(pack_id: StringName) -> SimulationContentPack: + var pack := _pack(pack_id) + var tag := CapabilityTagDefinition.new() + tag.tag_id = &"shared_tag" + tag.display_name = "Shared tag" + tag.domain = CapabilityTagDefinition.DOMAIN_ITEM + var item := _item(&"shared_item", tag.tag_id) + var action := _action(&"shared_action") + action.target_type = SimulationIds.TARGET_RESOURCE + action.resource_action_id = action.action_id + var resource := _resource(&"shared_resource", item.item_id, action.action_id) + var storage := _storage(&"shared_storage", tag.tag_id, 1) + pack.capability_tags = [tag] + pack.items = [item] + pack.actions = [action] + pack.resources = [resource] + pack.storages = [storage] + return pack + + +func _pack(pack_id: StringName) -> SimulationContentPack: + var pack := SimulationContentPack.new() + pack.pack_id = pack_id + pack.display_name = String(pack_id).capitalize() + return pack + + +func _item(item_id: StringName, tag_id: StringName) -> ItemDefinition: + var item := ItemDefinition.new() + item.item_id = item_id + item.display_name = String(item_id).capitalize() + item.category = ItemDefinition.CATEGORY_RESOURCE + item.capability_tag_ids = [tag_id] + 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 _resource( + resource_id: StringName, item_id: StringName, action_id: StringName +) -> ResourceDefinition: + var resource := ResourceDefinition.new() + resource.resource_id = resource_id + resource.display_name = String(resource_id).capitalize() + resource.item_id = item_id + resource.extraction_action_id = action_id + return resource + + +func _storage( + storage_id: StringName, accepted_item_tag_id: StringName, priority: int +) -> StorageDefinition: + var storage := StorageDefinition.new() + storage.storage_id = storage_id + storage.display_name = String(storage_id).capitalize() + storage.accepted_item_tag_ids = [accepted_item_tag_id] + storage.routing_priority = priority + return storage + + +func _tag_ids(definitions: Array[CapabilityTagDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for definition in definitions: + ids.append(definition.tag_id) + return ids + + +func _resource_ids(definitions: Array[ResourceDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for definition in definitions: + ids.append(definition.resource_id) + return ids + + +func _storage_ids(definitions: Array[StorageDefinition]) -> Array[StringName]: + var ids: Array[StringName] = [] + for definition in definitions: + ids.append(definition.storage_id) + return ids + + +func _has_error(errors: Array[String], fragment: String) -> bool: + for error in errors: + if fragment in error: + return true + return false diff --git a/tests/unit/test_content_routing_foundations.gd.uid b/tests/unit/test_content_routing_foundations.gd.uid new file mode 100644 index 0000000..eee62cf --- /dev/null +++ b/tests/unit/test_content_routing_foundations.gd.uid @@ -0,0 +1 @@ +uid://deq7osx44nnn3