## 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. 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] = [] func _init() -> void: validation_errors = [] ## 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") 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) return JSON.stringify(data, " ") ## 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() ## 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 ## 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)