# frozen_string_literal: true ## # Service for importing artifacts from game JSON data. # Matches skills by name (EN or JP) and stores quality tier for later strength calculation. # # @example Import artifacts for a user # service = ArtifactImportService.new(user, game_data) # result = service.import # if result.success? # puts "Imported #{result.created_count} artifacts" # end # class ArtifactImportService Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true) # Game element values to our element enum values # Game: 1=Fire, 2=Water, 3=Earth, 4=Wind, 5=Light, 6=Dark # Ours: wind=1, fire=2, water=3, earth=4, dark=5, light=6 ELEMENT_MAPPING = { '1' => 2, # Fire '2' => 3, # Water '3' => 4, # Earth '4' => 1, # Wind '5' => 6, # Light '6' => 5 # Dark }.freeze def initialize(user, game_data, options = {}) @user = user @game_data = game_data @update_existing = options[:update_existing] || false @is_full_inventory = options[:is_full_inventory] || false @reconcile_deletions = options[:reconcile_deletions] || false @created = [] @updated = [] @skipped = [] @errors = [] @processed_game_ids = [] end ## # Previews what would be deleted in a sync operation. # Does not modify any data, just returns items that would be removed. # # @return [Array] Collection artifacts that would be deleted def preview_deletions items = extract_items return [] if items.empty? # Extract all game_ids from the import data # Artifacts use 'id' directly (not nested in 'param') game_ids = items.filter_map do |item| data = item.is_a?(Hash) ? item.with_indifferent_access : item data['id'].to_s if data['id'].present? end return [] if game_ids.empty? # Find collection artifacts with game_ids NOT in the import @user.collection_artifacts .includes(:artifact) .where.not(game_id: nil) .where.not(game_id: game_ids) end ## # Imports artifacts from game data. # # @return [Result] Import result with counts and errors def import items = extract_items if items.empty? return Result.new( success?: false, created: [], updated: [], skipped: [], errors: ['No artifact items found in data'], reconciliation: nil ) end # Preload artifacts and existing collection artifacts to avoid N+1 queries preload_artifacts(items) preload_existing_collection_artifacts(items) ActiveRecord::Base.transaction do items.each_with_index do |item, index| import_item(item, index) rescue StandardError => e @errors << { index: index, game_id: item['id'], error: e.message } end end # Handle deletion reconciliation if requested reconciliation_result = nil if @reconcile_deletions && @is_full_inventory && @processed_game_ids.any? reconciliation_result = reconcile_deletions end Result.new( success?: @errors.empty?, created: @created, updated: @updated, skipped: @skipped, errors: @errors, reconciliation: reconciliation_result ) 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 preload_artifacts(items) artifact_ids = items.map { |item| (item['artifact_id'] || item[:artifact_id]).to_s }.uniq artifacts = Artifact.where(granblue_id: artifact_ids).index_by(&:granblue_id) @artifacts_cache = artifacts end def preload_existing_collection_artifacts(items) game_ids = items.map { |item| (item['id'] || item[:id]).to_s }.uniq existing = @user.collection_artifacts.where(game_id: game_ids).index_by(&:game_id) @existing_cache = existing end def import_item(item, _index) # Handle both string and symbol keys from params data = item.is_a?(Hash) ? item.with_indifferent_access : item # Track this game_id as processed (for reconciliation) game_id = data['id'] @processed_game_ids << game_id.to_s if game_id.present? artifact = find_artifact(data['artifact_id']) unless artifact @errors << { game_id: data['id'], artifact_id: data['artifact_id'], error: 'Artifact not found' } return end # Check for existing collection artifact with same game ID existing = @existing_cache[data['id'].to_s] if existing if @update_existing update_existing_artifact(existing, data, artifact) else @skipped << { game_id: data['id'], reason: 'Already exists' } end return end create_collection_artifact(data, artifact) end def find_artifact(artifact_id) @artifacts_cache[artifact_id.to_s] end def create_collection_artifact(item, artifact) attrs = build_collection_artifact_attrs(item, artifact) collection_artifact = @user.collection_artifacts.build(attrs) if collection_artifact.save @created << collection_artifact else @errors << { game_id: item['id'], artifact_id: item['artifact_id'], error: collection_artifact.errors.full_messages.join(', ') } end end def update_existing_artifact(existing, item, artifact) attrs = build_collection_artifact_attrs(item, artifact) if existing.update(attrs) @updated << existing else @errors << { game_id: item['id'], artifact_id: item['artifact_id'], error: existing.errors.full_messages.join(', ') } end end def build_collection_artifact_attrs(item, artifact) # Handle both string and symbol keys from params data = item.is_a?(Hash) ? item.with_indifferent_access : item { artifact: artifact, game_id: data['id'].to_s, element: map_element(data['attribute']), proficiency: artifact.quirk? ? map_proficiency(data['kind']) : nil, level: data['level'].to_i, skill1: parse_skill(data['skill1_info']), skill2: parse_skill(data['skill2_info']), skill3: parse_skill(data['skill3_info']), skill4: parse_skill(data['skill4_info']) } end def map_element(game_element) ELEMENT_MAPPING[game_element.to_s] end def map_proficiency(kind) # Game 'kind' field maps directly to proficiency enum (1-10) kind.to_i end def parse_skill(skill_info) return {} if skill_info.blank? # Handle both string and symbol keys from params info = skill_info.is_a?(Hash) ? skill_info.with_indifferent_access : skill_info name = info['name'] quality = info['skill_quality'] || info['level'] || 1 level = info['level'] || 1 # Look up skill by game name (supports both EN and JP) skill = ArtifactSkill.find_by_game_name(name) return {} unless skill { 'modifier' => skill.modifier, 'quality' => quality.to_i, 'level' => level.to_i } end ## # Reconciles deletions by removing collection artifacts not in the processed list. # Only called when @is_full_inventory and @reconcile_deletions are both true. # # @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs def reconcile_deletions # Find collection artifacts with game_ids NOT in our processed list missing = @user.collection_artifacts .where.not(game_id: nil) .where.not(game_id: @processed_game_ids) deleted_count = 0 orphaned_grid_item_ids = [] missing.find_each do |coll_artifact| # Collect IDs of grid items that will be orphaned grid_artifact_ids = GridArtifact.where(collection_artifact_id: coll_artifact.id).pluck(:id) orphaned_grid_item_ids.concat(grid_artifact_ids) # The before_destroy callback on CollectionArtifact will mark grid items as orphaned coll_artifact.destroy deleted_count += 1 end { deleted: deleted_count, orphaned_grid_items: orphaned_grid_item_ids } end end