* collection sync with orphan handling - preview_sync endpoint shows what'll get deleted before you commit - import services handle reconciliation (find missing items, delete them) - grid items get flagged as orphaned when their collection source is gone - party exposes has_orphaned_items - blueprints include orphaned field * scope artifact sync by element and proficiency - accept filter param in preview_sync and import - only check/delete items matching active filter - prevents accidental deletion of filtered-out items * scope weapon sync by element and proficiency - accept filter param in preview_sync and import - element checks collection_weapon first, falls back to weapon - proficiency joins through weapon table * scope summon sync by element - accept filter param in preview_sync and import - element joins through summon table
424 lines
12 KiB
Ruby
424 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
##
|
|
# Service for importing weapons from game JSON data.
|
|
# Parses the game's weapon inventory data and creates CollectionWeapon records.
|
|
#
|
|
# @example Import weapons for a user
|
|
# service = WeaponImportService.new(user, game_data)
|
|
# result = service.import
|
|
# if result.success?
|
|
# puts "Imported #{result.created.size} weapons"
|
|
# end
|
|
#
|
|
class WeaponImportService
|
|
Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true)
|
|
|
|
# Game awakening form to our slug mapping
|
|
AWAKENING_FORM_MAPPING = {
|
|
1 => 'weapon-atk', # Attack
|
|
2 => 'weapon-def', # Defense
|
|
3 => 'weapon-special', # Special
|
|
4 => 'weapon-ca', # C.A.
|
|
5 => 'weapon-skill', # Skill DMG
|
|
6 => 'weapon-heal' # Healing
|
|
}.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
|
|
@filter = options[:filter] # { elements: [...], proficiencies: [...] }
|
|
@created = []
|
|
@updated = []
|
|
@skipped = []
|
|
@errors = []
|
|
@awakening_cache = {}
|
|
@modifier_cache = {}
|
|
@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.
|
|
# When a filter is active, only considers items matching that filter.
|
|
#
|
|
# @return [Array<CollectionWeapon>] Collection weapons that would be deleted
|
|
def preview_deletions
|
|
items = extract_items
|
|
return [] if items.empty?
|
|
|
|
# Extract all game_ids from the import data
|
|
game_ids = items.filter_map do |item|
|
|
param = item['param'] || {}
|
|
param['id'].to_s if param['id'].present?
|
|
end
|
|
|
|
return [] if game_ids.empty?
|
|
|
|
# Find collection weapons with game_ids NOT in the import
|
|
# Scoped to filter criteria if present
|
|
scope = @user.collection_weapons
|
|
.includes(:weapon)
|
|
.where.not(game_id: nil)
|
|
.where.not(game_id: game_ids)
|
|
|
|
scope = apply_filter_scope(scope)
|
|
scope
|
|
end
|
|
|
|
##
|
|
# Imports weapons 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 weapon items found in data'],
|
|
reconciliation: nil
|
|
)
|
|
end
|
|
|
|
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
|
|
|
|
# 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 import_item(item, _index)
|
|
param = item['param'] || {}
|
|
master = item['master'] || {}
|
|
|
|
# The weapon's granblue_id can be in param.image_id or master.id
|
|
granblue_id = param['image_id'] || master['id']
|
|
game_id = param['id']
|
|
|
|
# Track this game_id as processed (for reconciliation)
|
|
@processed_game_ids << game_id.to_s if game_id.present?
|
|
|
|
weapon = find_weapon(granblue_id)
|
|
unless weapon
|
|
@errors << { game_id: game_id, granblue_id: granblue_id, error: 'Weapon not found' }
|
|
return
|
|
end
|
|
|
|
# Check for existing collection weapon with same game ID
|
|
existing = @user.collection_weapons.find_by(game_id: game_id.to_s)
|
|
|
|
if existing
|
|
if @update_existing
|
|
update_existing_weapon(existing, item, weapon)
|
|
else
|
|
@skipped << { game_id: game_id, reason: 'Already exists' }
|
|
end
|
|
return
|
|
end
|
|
|
|
create_collection_weapon(item, weapon)
|
|
end
|
|
|
|
def find_weapon(granblue_id)
|
|
Weapon.find_by(granblue_id: granblue_id.to_s)
|
|
end
|
|
|
|
def create_collection_weapon(item, weapon)
|
|
attrs = build_collection_weapon_attrs(item, weapon)
|
|
|
|
collection_weapon = @user.collection_weapons.build(attrs)
|
|
|
|
if collection_weapon.save
|
|
@created << collection_weapon
|
|
else
|
|
@errors << {
|
|
game_id: item.dig('param', 'id'),
|
|
granblue_id: weapon.granblue_id,
|
|
error: collection_weapon.errors.full_messages.join(', ')
|
|
}
|
|
end
|
|
end
|
|
|
|
def update_existing_weapon(existing, item, weapon)
|
|
attrs = build_collection_weapon_attrs(item, weapon)
|
|
|
|
if existing.update(attrs)
|
|
@updated << existing
|
|
else
|
|
@errors << {
|
|
game_id: item.dig('param', 'id'),
|
|
granblue_id: weapon.granblue_id,
|
|
error: existing.errors.full_messages.join(', ')
|
|
}
|
|
end
|
|
end
|
|
|
|
def build_collection_weapon_attrs(item, weapon)
|
|
param = item['param'] || {}
|
|
|
|
attrs = {
|
|
weapon: weapon,
|
|
game_id: param['id'].to_s,
|
|
uncap_level: parse_uncap_level(param['evolution']),
|
|
transcendence_step: parse_transcendence_step(param['phase'])
|
|
}
|
|
|
|
# Parse awakening if present
|
|
awakening_attrs = parse_awakening(param['arousal'])
|
|
attrs.merge!(awakening_attrs) if awakening_attrs
|
|
|
|
# Check if this is an Odiant (befoulment) weapon
|
|
odiant = param['odiant']
|
|
if odiant && odiant['is_odiant_weapon'] == true
|
|
# Parse befoulment from augment_skill_info
|
|
befoulment_attrs = parse_befoulment(param['augment_skill_info'])
|
|
attrs.merge!(befoulment_attrs) if befoulment_attrs
|
|
attrs[:exorcism_level] = odiant['exorcision_level'].to_i.clamp(0, 5)
|
|
else
|
|
# Regular weapon - parse AX skills
|
|
ax_attrs = parse_ax_skills(param['augment_skill_info'])
|
|
attrs.merge!(ax_attrs) if ax_attrs
|
|
end
|
|
|
|
attrs
|
|
end
|
|
|
|
def parse_uncap_level(evolution)
|
|
value = evolution.to_i
|
|
value.clamp(0, 5)
|
|
end
|
|
|
|
def parse_transcendence_step(phase)
|
|
value = phase.to_i
|
|
value.clamp(0, 10)
|
|
end
|
|
|
|
##
|
|
# Parses awakening data from game format.
|
|
# Game arousal data contains level and form info.
|
|
#
|
|
# @param arousal [Hash] The game's arousal (awakening) data
|
|
# @return [Hash, nil] Awakening attributes or nil if no awakening
|
|
def parse_awakening(arousal)
|
|
return nil if arousal.blank? || arousal['is_arousal_weapon'] != true
|
|
return nil if arousal['level'].blank?
|
|
|
|
form = arousal['form'].to_i
|
|
awakening = find_awakening_by_form(form)
|
|
return nil unless awakening
|
|
|
|
{
|
|
awakening_id: awakening.id,
|
|
awakening_level: arousal['level'].to_i.clamp(1, 20)
|
|
}
|
|
end
|
|
|
|
def find_awakening_by_form(form)
|
|
slug = AWAKENING_FORM_MAPPING[form]
|
|
return nil unless slug
|
|
|
|
@awakening_cache[slug] ||= Awakening.find_by(slug: slug, object_type: 'Weapon')
|
|
end
|
|
|
|
##
|
|
# Parses AX skill data from game format.
|
|
# Game augment_skill_info is an array of skill arrays.
|
|
#
|
|
# @param augment_skill_info [Array] The game's AX skill data
|
|
# @return [Hash, nil] AX skill attributes or nil if no AX skills
|
|
def parse_ax_skills(augment_skill_info)
|
|
return nil if augment_skill_info.blank? || !augment_skill_info.is_a?(Array)
|
|
|
|
# First entry in augment_skill_info is an array of skills
|
|
skills = augment_skill_info.first
|
|
return nil if skills.blank? || !skills.is_a?(Array)
|
|
|
|
attrs = {}
|
|
|
|
# First AX skill
|
|
if skills[0].is_a?(Hash)
|
|
ax1 = parse_single_augment_skill(skills[0])
|
|
if ax1
|
|
attrs[:ax_modifier1_id] = ax1[:modifier_id]
|
|
attrs[:ax_strength1] = ax1[:strength]
|
|
end
|
|
end
|
|
|
|
# Second AX skill
|
|
if skills[1].is_a?(Hash)
|
|
ax2 = parse_single_augment_skill(skills[1])
|
|
if ax2
|
|
attrs[:ax_modifier2_id] = ax2[:modifier_id]
|
|
attrs[:ax_strength2] = ax2[:strength]
|
|
end
|
|
end
|
|
|
|
attrs.empty? ? nil : attrs
|
|
end
|
|
|
|
##
|
|
# Parses befoulment data from game format.
|
|
# Odiant weapons have a single befoulment in augment_skill_info.
|
|
#
|
|
# @param augment_skill_info [Array] The game's augment skill data
|
|
# @return [Hash, nil] Befoulment attributes or nil if no befoulment
|
|
def parse_befoulment(augment_skill_info)
|
|
return nil if augment_skill_info.blank? || !augment_skill_info.is_a?(Array)
|
|
|
|
skills = augment_skill_info.first
|
|
return nil if skills.blank? || !skills.is_a?(Array)
|
|
|
|
skill = skills.first
|
|
return nil unless skill.is_a?(Hash)
|
|
|
|
result = parse_single_augment_skill(skill)
|
|
return nil unless result
|
|
|
|
{
|
|
befoulment_modifier_id: result[:modifier_id],
|
|
befoulment_strength: result[:strength]
|
|
}
|
|
end
|
|
|
|
##
|
|
# Parses a single augment skill (AX or befoulment) from game data.
|
|
#
|
|
# @param skill [Hash] Single skill data with skill_id and effect_value
|
|
# @return [Hash, nil] { modifier_id:, strength: } or nil
|
|
def parse_single_augment_skill(skill)
|
|
return nil unless skill['skill_id'].present?
|
|
|
|
game_skill_id = skill['skill_id'].to_i
|
|
modifier = find_modifier_by_game_skill_id(game_skill_id)
|
|
|
|
unless modifier
|
|
# Log unknown skill ID with icon for discovery
|
|
Rails.logger.warn(
|
|
"[WeaponImportService] Unknown augment skill_id=#{game_skill_id} " \
|
|
"icon=#{skill['augment_skill_icon_image']}"
|
|
)
|
|
return nil
|
|
end
|
|
|
|
strength = parse_augment_strength(skill['effect_value'], skill['show_value'])
|
|
return nil unless strength
|
|
|
|
{ modifier_id: modifier.id, strength: strength }
|
|
end
|
|
|
|
def find_modifier_by_game_skill_id(game_skill_id)
|
|
@modifier_cache[game_skill_id] ||= WeaponStatModifier.find_by(game_skill_id: game_skill_id)
|
|
end
|
|
|
|
def parse_augment_strength(effect_value, show_value)
|
|
# Try effect_value first
|
|
if effect_value.present?
|
|
# Handle "1_3" format (seems to be "tier_value")
|
|
if effect_value.to_s.include?('_')
|
|
return effect_value.to_s.split('_').last.to_f
|
|
end
|
|
return effect_value.to_f if effect_value.to_s.match?(/\A[\d.]+\z/)
|
|
end
|
|
|
|
# Try show_value (e.g., "3%")
|
|
if show_value.present?
|
|
return show_value.to_s.gsub('%', '').to_f
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
##
|
|
# Reconciles deletions by removing collection weapons not in the processed list.
|
|
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
|
# When a filter is active, only deletes items matching that filter.
|
|
#
|
|
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
|
def reconcile_deletions
|
|
# Find collection weapons with game_ids NOT in our processed list
|
|
# Scoped to filter criteria if present
|
|
scope = @user.collection_weapons
|
|
.where.not(game_id: nil)
|
|
.where.not(game_id: @processed_game_ids)
|
|
|
|
scope = apply_filter_scope(scope)
|
|
|
|
deleted_count = 0
|
|
orphaned_grid_item_ids = []
|
|
|
|
scope.find_each do |coll_weapon|
|
|
# Collect IDs of grid items that will be orphaned
|
|
grid_weapon_ids = GridWeapon.where(collection_weapon_id: coll_weapon.id).pluck(:id)
|
|
orphaned_grid_item_ids.concat(grid_weapon_ids)
|
|
|
|
# The before_destroy callback on CollectionWeapon will mark grid items as orphaned
|
|
coll_weapon.destroy
|
|
deleted_count += 1
|
|
end
|
|
|
|
{
|
|
deleted: deleted_count,
|
|
orphaned_grid_items: orphaned_grid_item_ids
|
|
}
|
|
end
|
|
|
|
##
|
|
# Applies element and proficiency filters to a collection weapons scope.
|
|
# Used to scope deletion checks to only items matching the current game filter.
|
|
#
|
|
# @param scope [ActiveRecord::Relation] The collection weapons relation to filter
|
|
# @return [ActiveRecord::Relation] Filtered relation
|
|
def apply_filter_scope(scope)
|
|
return scope unless @filter.present?
|
|
|
|
# Element: check collection_weapon.element first (for element-changeable weapons),
|
|
# fall back to weapon.element if nil
|
|
if @filter[:elements].present? || @filter['elements'].present?
|
|
elements = @filter[:elements] || @filter['elements']
|
|
scope = scope.joins(:weapon).where(
|
|
'collection_weapons.element IN (?) OR (collection_weapons.element IS NULL AND weapons.element IN (?))',
|
|
elements, elements
|
|
)
|
|
end
|
|
|
|
# Proficiency: join through weapon
|
|
if @filter[:proficiencies].present? || @filter['proficiencies'].present?
|
|
proficiencies = @filter[:proficiencies] || @filter['proficiencies']
|
|
scope = scope.joins(:weapon).where(weapons: { proficiency: proficiencies })
|
|
end
|
|
|
|
scope
|
|
end
|
|
end
|