# frozen_string_literal: true ## # Service for importing characters from game JSON data. # Parses the game's character inventory data and creates CollectionCharacter records. # # Supports two data formats: # 1. Game inventory format: { param: { id: ... }, master: { id: granblue_id } } # 2. Extension stats format: { granblue_id: '...', awakening_type: 1, ring1: {...}, ... } # # Note: Unlike weapons and summons, characters are unique per user - each character # can only be in a user's collection once. # # @example Import characters for a user # service = CharacterImportService.new(user, game_data) # result = service.import # if result.success? # puts "Imported #{result.created.size} characters" # end # class CharacterImportService Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) # Map GBF npc_arousal_form to hensei awakening slugs # GBF character awakenings: 1=Attack, 2=Defense, 3=Multiattack, others default to Balanced GBF_AWAKENING_MAP = { 1 => 'character-atk', # Attack 2 => 'character-def', # Defense 3 => 'character-multi' # Multiattack # All others default to character-balanced }.freeze def initialize(user, game_data, options = {}) @user = user @game_data = game_data @update_existing = options[:update_existing] || false @created = [] @updated = [] @skipped = [] @errors = [] @default_awakening = nil @awakening_cache = {} end ## # Imports characters from game data. # # @return [Result] Import result with counts and errors def import items = extract_items return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No character items found in data']) if items.empty? ActiveRecord::Base.transaction do items.each_with_index do |item, index| import_item(item, index) rescue StandardError => e @errors << { index: index, game_id: item.dig('param', 'id'), error: e.message } end end Result.new( success?: @errors.empty?, created: @created, updated: @updated, skipped: @skipped, errors: @errors ) end private def extract_items return @game_data if @game_data.is_a?(Array) return @game_data['list'] if @game_data.is_a?(Hash) && @game_data['list'].is_a?(Array) [] end def import_item(item, _index) # Detect format: extension stats format has granblue_id at top level if item['granblue_id'].present? import_stats_format(item) else import_game_format(item) end end # Import from game inventory format: { param: {...}, master: { id: granblue_id } } def import_game_format(item) param = item['param'] || {} master = item['master'] || {} granblue_id = master['id'] game_id = param['id'] character = find_character(granblue_id) unless character @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Character not found' } return end existing = @user.collection_characters.find_by(character_id: character.id) if existing if @update_existing update_existing_character(existing, item, character) else @skipped << { game_id: game_id, character_id: character.id, reason: 'Already exists' } end return end create_collection_character(item, character) end # Import from extension stats format: { granblue_id: '...', awakening_type: 1, ring1: {...}, ... } def import_stats_format(item) granblue_id = item['granblue_id'] character = find_character(granblue_id) unless character @errors << { granblue_id: granblue_id, error: 'Character not found' } return end existing = @user.collection_characters.find_by(character_id: character.id) if existing if @update_existing update_existing_stats(existing, item, character) else @skipped << { granblue_id: granblue_id, character_id: character.id, reason: 'Already exists' } end return end create_collection_character_from_stats(item, character) end def find_character(granblue_id) Character.find_by(granblue_id: granblue_id.to_s) end def create_collection_character(item, character) attrs = build_collection_character_attrs(item, character) collection_character = @user.collection_characters.build(attrs) if collection_character.save @created << collection_character else @errors << { game_id: item.dig('param', 'id'), granblue_id: character.granblue_id, error: collection_character.errors.full_messages.join(', ') } end end def update_existing_character(existing, item, character) attrs = build_collection_character_attrs(item, character) if existing.update(attrs) @updated << existing else @errors << { game_id: item.dig('param', 'id'), granblue_id: character.granblue_id, error: existing.errors.full_messages.join(', ') } end end def build_collection_character_attrs(item, character) param = item['param'] || {} awakening_level = parse_awakening_level(param['arousal_level']) attrs = { character: character, uncap_level: parse_uncap_level(param['evolution']), transcendence_step: parse_transcendence_step(param['phase']) } # Only set awakening_level if > 1 (requires awakening to be set) # The model's before_save callback will set default awakening if awakening_level > 1 attrs[:awakening] = default_awakening attrs[:awakening_level] = awakening_level end attrs end def default_awakening @default_awakening ||= Awakening.find_by(slug: 'character-balanced', object_type: 'Character') end def parse_uncap_level(evolution) value = evolution.to_i # Evolution 6 = transcended, but uncap_level maxes at 5 value.clamp(0, 5) end def parse_transcendence_step(phase) value = phase.to_i value.clamp(0, 10) end def parse_awakening_level(arousal_level) value = arousal_level.to_i # Default to 1 if not present or 0 value = 1 if value < 1 value.clamp(1, 10) end # Stats format methods def create_collection_character_from_stats(item, character) attrs = build_stats_attrs(item, character) collection_character = @user.collection_characters.build(attrs) if collection_character.save @created << collection_character else @errors << { granblue_id: character.granblue_id, error: collection_character.errors.full_messages.join(', ') } end end def update_existing_stats(existing, item, character) attrs = build_stats_attrs(item, character) if existing.update(attrs) @updated << existing else @errors << { granblue_id: character.granblue_id, error: existing.errors.full_messages.join(', ') } end end def build_stats_attrs(item, character) attrs = { character: character } # Awakening type and level if item['awakening_type'].present? attrs[:awakening] = find_awakening_by_type(item['awakening_type']) attrs[:awakening_level] = parse_awakening_level(item['awakening_level']) end # Rings (up to 4) attrs[:ring1] = parse_ring(item['ring1']) if item['ring1'].present? attrs[:ring2] = parse_ring(item['ring2']) if item['ring2'].present? attrs[:ring3] = parse_ring(item['ring3']) if item['ring3'].present? attrs[:ring4] = parse_ring(item['ring4']) if item['ring4'].present? # Earring attrs[:earring] = parse_ring(item['earring']) if item['earring'].present? # Perpetuity ring status attrs[:perpetuity] = item['perpetuity'] if item.key?('perpetuity') attrs end def find_awakening_by_type(gbf_type) slug = GBF_AWAKENING_MAP[gbf_type.to_i] || 'character-balanced' @awakening_cache[slug] ||= Awakening.find_by(slug: slug, object_type: 'Character') end def parse_ring(ring_data) return nil unless ring_data.is_a?(Hash) return nil unless ring_data['modifier'].present? { 'modifier' => ring_data['modifier'].to_i, 'strength' => ring_data['strength'].to_i } end end