## AvatarCatalog - Asset catalog management. ## ## Manages the mapping of asset IDs to their metadata and resource paths. ## Loads the catalog manifest from disk and provides lookup by ID and slot. ## ## The catalog is loaded from `res://assets/avatars/catalog.json` which follows ## the format specified in avatar.md §7.2. class_name AvatarCatalog extends RefCounted # Preloaded rather than referenced by global class_name -- see avatar_loadout.gd. const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd") ## Emitted when the catalog is loaded successfully. signal catalog_loaded ## Emitted when the catalog fails to load. signal catalog_load_failed(error_message: String) ## Catalog manifest structure: ## { "assets": { "id": { "type", "slot", "path", "version", ... } } } var _catalog: Dictionary = {} var _loaded: bool = false var _catalog_path: String = "res://assets/avatars/catalog.json" # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## Load the catalog from disk. Call this before any lookups. func load_catalog(path: String = _catalog_path) -> Error: _catalog_path = path _catalog = {} var file = FileAccess.open(path, FileAccess.READ) if file == null: var err = FileAccess.get_open_error() catalog_load_failed.emit("Cannot open catalog: %s (error %d)" % [path, err]) return err var json_text: String = file.get_as_text() file.close() var result: Variant = JSON.parse_string(json_text) if result == null or not result is Dictionary: catalog_load_failed.emit("Invalid JSON in catalog: %s" % path) return ERR_PARSE_ERROR var json: Dictionary = result as Dictionary if not json.has("assets"): catalog_load_failed.emit("Catalog missing 'assets' key") return ERR_INVALID_DATA _catalog = json["assets"] _loaded = true catalog_loaded.emit() return OK ## Check if the catalog has been loaded. func is_loaded() -> bool: return _loaded ## Resolve an asset by its ID. ## Returns a Dictionary with keys: type, slot, path, version, etc. ## Returns null if the asset ID is not found. func resolve(id: String) -> Dictionary: if not _loaded: return {} return _catalog.get(id, {}) ## Check if an asset ID exists in the catalog. func has_asset(id: String) -> bool: if not _loaded: return false return _catalog.has(id) ## Get all assets that belong to a given slot. ## Returns an array of asset ID strings. func get_assets_by_slot(slot: String) -> Array[String]: if not _loaded: return [] var result: Array[String] = [] for id in _catalog: var asset: Dictionary = _catalog[id] if asset.get("slot") == slot: result.append(id) return result ## Get all asset IDs in the catalog. func get_all_ids() -> Array[String]: if not _loaded: return [] return _catalog.keys() ## Validate a loadout against the catalog. ## Returns a Dictionary with "valid" (bool) and "errors" (Array[String]). func validate_loadout(loadout: AvatarLoadout) -> Dictionary: var errors: Array[String] = [] # Check base_body if loadout.base_body.is_empty(): errors.append("Loadout missing base_body") elif not has_asset(loadout.base_body): errors.append("Unknown base_body asset: %s" % loadout.base_body) else: var base: Dictionary = resolve(loadout.base_body) if base.get("type") != "base_body": errors.append("base_body asset '%s' is not type 'base_body'" % loadout.base_body) # Check each slot for slot_id in loadout.slots: var slot_value = loadout.slots[slot_id] if slot_value == null: continue if slot_id == "accessory": # Accessory is an array if not slot_value is Array: errors.append("Slot '%s' must be an array" % slot_id) continue for item_id in slot_value: if not has_asset(item_id): errors.append("Unknown accessory asset: %s" % item_id) else: var asset: Dictionary = resolve(item_id) if asset.get("slot") != slot_id: errors.append("Accessory '%s' slot '%s' does not match slot '%s'" % [ item_id, asset.get("slot"), slot_id ]) else: # Exclusive slot - single value if not slot_value is String: errors.append("Slot '%s' must be a string or null" % slot_id) continue if not has_asset(slot_value): errors.append("Unknown asset in slot '%s': %s" % [slot_id, slot_value]) else: var asset: Dictionary = resolve(slot_value) if asset.get("slot") != slot_id: errors.append("Asset '%s' slot '%s' does not match slot '%s'" % [ slot_value, asset.get("slot"), slot_id ]) return { "valid": errors.is_empty(), "errors": errors } ## Compute a hash of the loadout for change detection. ## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int ## is 64-bit signed, the value is just constrained to 32 bits of range). static func compute_loadout_hash(loadout: AvatarLoadout) -> int: var hasher = HashingContext.new() hasher.start(HashingContext.HASH_SHA256) hasher.update(loadout.base_body.to_utf8_buffer()) for slot_id in loadout.slots: var slot_value = loadout.slots[slot_id] hasher.update(slot_id.to_utf8_buffer()) if slot_value is Array: for item in slot_value: hasher.update(item.to_utf8_buffer()) elif slot_value is String: hasher.update(slot_value.to_utf8_buffer()) var hash_bytes = hasher.finish() return hash_bytes[0] | (hash_bytes[1] << 8) | (hash_bytes[2] << 16) | (hash_bytes[3] << 24) ## Get the catalog path. func get_catalog_path() -> String: return _catalog_path