## AvatarLoadout - Serializable loadout model + validation. ## ## Represents a complete avatar composition: a base body plus equipped items ## per slot. Validates slot exclusivity and references against a catalog. ## ## See docs/architecture/avatar.md ยง5 for the loadout JSON format. class_name AvatarLoadout extends RefCounted ## Maximum number of accessories allowed in the accessory slot. const MAX_ACCESSORIES: int = 8 ## The base body asset ID. var base_body: String = "" ## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry. ## Exclusive slots hold a single asset ID (or null). ## The "accessory" slot holds an array of asset IDs. var slots: Dictionary = {} ## Validation errors found during the last validate() call. var validation_errors: Array[String] = [] ## Skin tone color override (default: transparent = use asset colors). var skin_tone: Color = Color.TRANSPARENT # --------------------------------------------------------------------------- # Inner classes # --------------------------------------------------------------------------- ## Represents a single equipped item in a slot. ## Used by AvatarVisualizer to iterate over equipped items. class SlotItem: ## The slot this item belongs to. var slot_id: String ## The catalog asset ID of the equipped item. var asset_id: String func _init(p_slot_id: String, p_asset_id: String) -> void: slot_id = p_slot_id asset_id = p_asset_id # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _init() -> void: validation_errors = [] # --------------------------------------------------------------------------- # Serialization # --------------------------------------------------------------------------- ## Create a loadout from a JSON string. static func from_json(json_string: String) -> AvatarLoadout: var loadout := AvatarLoadout.new() var data: Variant = JSON.parse_string(json_string) if data is not Dictionary: loadout.validation_errors.append("Loadout JSON root is not an object") return loadout var root: Dictionary = data as Dictionary # Parse base_body if root.has("base_body"): var bb = root["base_body"] if typeof(bb) == TYPE_STRING: loadout.base_body = bb as String else: loadout.validation_errors.append("base_body must be a string") # Parse slots if root.has("slots"): var slots_data = root["slots"] if typeof(slots_data) == TYPE_DICTIONARY: loadout.slots = slots_data as Dictionary else: loadout.validation_errors.append("slots must be an object") else: loadout.validation_errors.append("Missing 'slots' in loadout JSON") # Parse skin_tone if root.has("skin_tone"): var st = root["skin_tone"] if typeof(st) == TYPE_DICTIONARY: loadout.skin_tone = Color( st.get("r", 0.0), st.get("g", 0.0), st.get("b", 0.0), st.get("a", 1.0) ) return loadout ## Serialize this loadout to a JSON string. func to_json() -> String: var data := { "base_body": base_body, "slots": {}, } for slot_id in AvatarSlotRegistry.SLOT_IDS: if slot_id == "accessory": data.slots[slot_id] = slots.get(slot_id, []) else: data.slots[slot_id] = slots.get(slot_id, null) # Include skin_tone if set if skin_tone != Color.TRANSPARENT: data["skin_tone"] = { "r": skin_tone.r, "g": skin_tone.g, "b": skin_tone.b, "a": skin_tone.a, } return JSON.stringify(data, " ") # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- ## Validate this loadout against the slot registry. ## Returns true if valid; populates validation_errors if not. func validate() -> bool: validation_errors.clear() # Check base_body if base_body.is_empty(): validation_errors.append("base_body is required") # Check each slot for slot_id in AvatarSlotRegistry.SLOT_IDS: var slot_def = AvatarSlotRegistry.get_slot(slot_id) if slot_def == null: validation_errors.append("Unknown slot: %s" % slot_id) continue var value = slots.get(slot_id) if slot_def.exclusive: # Exclusive slots: must be null or a string if value != null and typeof(value) != TYPE_STRING: validation_errors.append( "Slot '%s' must be null or a string, got %s" % [ slot_id, typeof(value) ] ) else: # Non-exclusive (accessory): must be null or an array if value != null and typeof(value) != TYPE_ARRAY: validation_errors.append( "Slot '%s' must be null or an array, got %s" % [ slot_id, typeof(value) ] ) elif value is Array: if slot_id == "accessory": if value.size() > MAX_ACCESSORIES: validation_errors.append( "Accessory count (%d) exceeds maximum (%d)" % [ value.size(), MAX_ACCESSORIES ] ) # Check for duplicate accessories if slots.has("accessory") and slots["accessory"] is Array: var accessories = slots["accessory"] as Array var seen := {} for item in accessories: if item is String: if seen.has(item): validation_errors.append( "Duplicate accessory: %s" % item ) seen[item] = true return validation_errors.is_empty() ## Convenience alias for validate(). func is_valid() -> bool: return validate() # --------------------------------------------------------------------------- # Accessors # --------------------------------------------------------------------------- ## Check if a specific slot is equipped with a non-null item. func is_equipped(slot_id: String) -> bool: if not AvatarSlotRegistry.has_slot(slot_id): return false var value = slots.get(slot_id) if value == null: return false if typeof(value) == TYPE_STRING: return not (value as String).is_empty() elif typeof(value) == TYPE_ARRAY: return (value as Array).size() > 0 return false ## Get the equipped asset ID for an exclusive slot. ## Returns empty string if not equipped or slot is not exclusive. func get_equipment(slot_id: String) -> String: var value = slots.get(slot_id) if value is String: return value return "" ## Set equipment for an exclusive slot. func set_equipment(slot_id: String, asset_id: String) -> void: if not AvatarSlotRegistry.has_slot(slot_id): push_error("Unknown slot: %s" % slot_id) return var slot_def = AvatarSlotRegistry.get_slot(slot_id) if slot_def and not slot_def.exclusive: push_error("Slot '%s' is not exclusive" % slot_id) return if slots.has(slot_id): var current = slots[slot_id] if current is Array: # Convert array to single value slots[slot_id] = asset_id if asset_id.is_empty() else null else: slots[slot_id] = asset_id if not asset_id.is_empty() else null else: slots[slot_id] = asset_id if not asset_id.is_empty() else null ## Add an accessory to the accessory slot. func add_accessory(asset_id: String) -> bool: var accessories = slots.get("accessory", []) as Array if accessories.size() >= MAX_ACCESSORIES: return false if asset_id.is_empty(): return false if accessories.has(asset_id): return false accessories.append(asset_id) slots["accessory"] = accessories return true ## Remove an accessory from the accessory slot. func remove_accessory(asset_id: String) -> bool: var accessories = slots.get("accessory", []) as Array if accessories.has(asset_id): accessories.erase(asset_id) slots["accessory"] = accessories return true return false ## Iterate over equipped items as SlotItem objects. ## Returns an array of SlotItem for each equipped slot. func get_equipped_items() -> Array[SlotItem]: var items: Array[SlotItem] = [] for slot_id in AvatarSlotRegistry.SLOT_IDS: var value = slots.get(slot_id) if value == null: continue if typeof(value) == TYPE_STRING: var asset_id = value as String if not asset_id.is_empty(): items.append(SlotItem.new(slot_id, asset_id)) elif typeof(value) == TYPE_ARRAY: for item_id in value: if item_id is String and not item_id.is_empty(): items.append(SlotItem.new(slot_id, item_id)) return items # --------------------------------------------------------------------------- # Hashing # --------------------------------------------------------------------------- ## Compute a hash of this loadout for change detection. ## Mirrors the server's loadout_hash concept. func compute_hash() -> int: var hasher := HashingContext.new() hasher.start(HashingContext.HASH_SHA256) hasher.update(base_body.to_utf8_buffer()) for slot_id in AvatarSlotRegistry.SLOT_IDS: hasher.update(slot_id.to_utf8_buffer()) var value = slots.get(slot_id) if value is String: hasher.update(value.to_utf8_buffer()) elif value is Array: var arr_str = JSON.stringify(value) hasher.update(arr_str.to_utf8_buffer()) else: hasher.update("null".to_utf8_buffer()) var digest = hasher.finish() # Return first 4 bytes as int for compact storage return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24)