add import services for characters, weapons, summons

parses game JSON inventory data and creates collection records

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-12-13 20:54:38 -08:00
parent c498278c89
commit 272f612357
6 changed files with 2279 additions and 0 deletions

View file

@ -0,0 +1,169 @@
# frozen_string_literal: true
##
# Service for importing characters from game JSON data.
# Parses the game's character inventory data and creates CollectionCharacter records.
#
# 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)
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
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)
param = item['param'] || {}
master = item['master'] || {}
# The character's granblue_id is in master.id
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
# Characters are unique per user - check by character_id, not game_id
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
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
end

View file

@ -0,0 +1,146 @@
# frozen_string_literal: true
##
# Service for importing summons from game JSON data.
# Parses the game's summon inventory data and creates CollectionSummon records.
#
# @example Import summons for a user
# service = SummonImportService.new(user, game_data)
# result = service.import
# if result.success?
# puts "Imported #{result.created.size} summons"
# end
#
class SummonImportService
Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true)
def initialize(user, game_data, options = {})
@user = user
@game_data = game_data
@update_existing = options[:update_existing] || false
@created = []
@updated = []
@skipped = []
@errors = []
end
##
# Imports summons 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 summon 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)
param = item['param'] || {}
master = item['master'] || {}
# The summon's granblue_id can be in param.image_id or master.id
# image_id may have a suffix like "_04" for transcended summons, so strip it
image_id = param['image_id'].to_s.split('_').first if param['image_id'].present?
granblue_id = image_id || master['id']
game_id = param['id']
summon = find_summon(granblue_id)
unless summon
@errors << { game_id: game_id, granblue_id: granblue_id, error: 'Summon not found' }
return
end
# Check for existing collection summon with same game ID
existing = @user.collection_summons.find_by(game_id: game_id.to_s)
if existing
if @update_existing
update_existing_summon(existing, item, summon)
else
@skipped << { game_id: game_id, reason: 'Already exists' }
end
return
end
create_collection_summon(item, summon)
end
def find_summon(granblue_id)
Summon.find_by(granblue_id: granblue_id.to_s)
end
def create_collection_summon(item, summon)
attrs = build_collection_summon_attrs(item, summon)
collection_summon = @user.collection_summons.build(attrs)
if collection_summon.save
@created << collection_summon
else
@errors << {
game_id: item.dig('param', 'id'),
granblue_id: summon.granblue_id,
error: collection_summon.errors.full_messages.join(', ')
}
end
end
def update_existing_summon(existing, item, summon)
attrs = build_collection_summon_attrs(item, summon)
if existing.update(attrs)
@updated << existing
else
@errors << {
game_id: item.dig('param', 'id'),
granblue_id: summon.granblue_id,
error: existing.errors.full_messages.join(', ')
}
end
end
def build_collection_summon_attrs(item, summon)
param = item['param'] || {}
{
summon: summon,
game_id: param['id'].to_s,
uncap_level: parse_uncap_level(param['evolution']),
transcendence_step: parse_transcendence_step(param['phase'])
}
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
end

View file

@ -0,0 +1,266 @@
# 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, 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
@created = []
@updated = []
@skipped = []
@errors = []
@awakening_cache = {}
end
##
# Imports weapons 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 weapon 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)
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']
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
# Parse AX skills if present
ax_attrs = parse_ax_skills(param['augment_skill_info'])
attrs.merge!(ax_attrs) if ax_attrs
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_ax_skill(skills[0])
if ax1
attrs[:ax_modifier1] = ax1[:modifier]
attrs[:ax_strength1] = ax1[:strength]
end
end
# Second AX skill
if skills[1].is_a?(Hash)
ax2 = parse_single_ax_skill(skills[1])
if ax2
attrs[:ax_modifier2] = ax2[:modifier]
attrs[:ax_strength2] = ax2[:strength]
end
end
attrs.empty? ? nil : attrs
end
##
# Parses a single AX skill from game data.
#
# @param skill [Hash] Single AX skill data with skill_id and effect_value
# @return [Hash, nil] { modifier:, strength: } or nil
def parse_single_ax_skill(skill)
return nil unless skill['skill_id'].present?
# The skill_id maps to our AX modifier
modifier = skill['skill_id'].to_i
# Parse strength from effect_value (may be "3" or "1_3" format)
# or from show_value (may be "3%" format)
strength = parse_ax_strength(skill['effect_value'], skill['show_value'])
return nil unless strength
{ modifier: modifier, strength: strength }
end
def parse_ax_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
end

View file

@ -0,0 +1,528 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CharacterImportService, type: :service do
let(:user) { create(:user) }
# Create character awakening first (required by model's before_save callback)
let!(:awakening_balanced) do
Awakening.find_by(slug: 'character-balanced', object_type: 'Character') ||
create(:awakening, :for_character, slug: 'character-balanced', name_en: 'Balanced')
end
# Create characters with specific granblue_ids matching the game data
# Use unique IDs that won't conflict with seeded data
let(:standard_character) do
Character.find_by(granblue_id: '3040171000') ||
create(:character, granblue_id: '3040171000', name_en: 'Hallessena')
end
let(:flb_character) do
Character.find_by(granblue_id: '3040167000') ||
create(:character, granblue_id: '3040167000', name_en: 'Zeta')
end
let(:transcendable_character) do
Character.find_by(granblue_id: '3040036000') ||
create(:character, :transcendable, granblue_id: '3040036000', name_en: 'Siegfried')
end
let(:another_character) do
Character.find_by(granblue_id: '3040212000') ||
create(:character, granblue_id: '3040212000', name_en: 'Narmaya (Summer)')
end
before do
standard_character
flb_character
transcendable_character
another_character
end
describe '#import' do
context 'with valid game data' do
let(:game_data) do
{
'list' => [
{
'master' => {
'id' => '3040171000',
'max_evolution_level' => 4
},
'param' => {
'id' => 129_355_003,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 1
}
}
]
}
end
it 'creates a collection character' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(1)
expect(result.errors).to be_empty
end
it 'sets the correct uncap_level from evolution' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.uncap_level).to eq(4)
end
it 'associates the correct character via granblue_id' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.character.granblue_id).to eq('3040171000')
end
it 'sets awakening_level from arousal_level' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.awakening_level).to eq(1)
end
end
context 'with FLB character (evolution 5)' do
let(:game_data) do
{
'list' => [
{
'master' => {
'id' => '3040167000',
'max_evolution_level' => 5
},
'param' => {
'id' => 128_935_603,
'evolution' => '5',
'phase' => '0',
'arousal_level' => 9
}
}
]
}
end
it 'sets uncap_level to 5' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.uncap_level).to eq(5)
end
it 'sets awakening_level from arousal_level' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.awakening_level).to eq(9)
end
end
context 'with transcendence data (evolution 6, phase 5)' do
let(:game_data) do
{
'list' => [
{
'master' => {
'id' => '3040036000',
'max_evolution_level' => 6
},
'param' => {
'id' => 128_343_789,
'evolution' => '6',
'phase' => '5',
'arousal_level' => 10
}
}
]
}
end
it 'clamps uncap_level to 5 even when evolution is 6' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.uncap_level).to eq(5)
end
it 'sets transcendence_step from phase' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.transcendence_step).to eq(5)
end
it 'sets max awakening_level' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.awakening_level).to eq(10)
end
end
context 'with duplicate character (unique per user)' do
let(:game_data) do
{
'list' => [
{
'master' => {
'id' => '3040171000'
},
'param' => {
'id' => 999_999_999,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 5
}
}
]
}
end
before do
create(:collection_character, user: user, character: standard_character)
end
it 'skips the duplicate based on character_id (not game_id)' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(0)
expect(result.skipped.size).to eq(1)
expect(result.skipped.first[:reason]).to eq('Already exists')
end
context 'with update_existing: true' do
let(:game_data_updated) do
{
'list' => [
{
'master' => {
'id' => '3040171000'
},
'param' => {
'id' => 999_999_999,
'evolution' => '5',
'phase' => '0',
'arousal_level' => 10
}
}
]
}
end
it 'updates the existing character' do
service = described_class.new(user, game_data_updated, update_existing: true)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(0)
expect(result.updated.size).to eq(1)
expect(result.updated.first.uncap_level).to eq(5)
expect(result.updated.first.awakening_level).to eq(10)
end
end
end
context 'with unknown character' do
let(:game_data) do
{
'list' => [
{
'master' => {
'id' => '9999999999'
},
'param' => {
'id' => 12_345,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 1
}
}
]
}
end
it 'records an error for the unknown character' do
service = described_class.new(user, game_data)
result = service.import
expect(result.errors.size).to eq(1)
expect(result.errors.first[:error]).to eq('Character not found')
end
end
context 'with multiple characters' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 111_111_111,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 1
}
},
{
'master' => { 'id' => '3040212000' },
'param' => {
'id' => 222_222_222,
'evolution' => '5',
'phase' => '0',
'arousal_level' => 7
}
}
]
}
end
it 'imports all characters' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(2)
end
it 'associates correct characters' do
service = described_class.new(user, game_data)
result = service.import
granblue_ids = result.created.map { |c| c.character.granblue_id }.sort
expect(granblue_ids).to eq(%w[3040171000 3040212000])
end
end
context 'with empty data' do
let(:game_data) { { 'list' => [] } }
it 'returns an error' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be false
expect(result.errors).to include('No character items found in data')
end
end
context 'with array format data' do
let(:game_data) do
[
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 777_777_777,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 5
}
}
]
end
it 'handles array format correctly' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(1)
expect(result.created.first.awakening_level).to eq(5)
end
end
context 'with max values' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040036000' },
'param' => {
'id' => 888_888_888,
'evolution' => '10',
'phase' => '15',
'arousal_level' => 99
}
}
]
}
end
it 'clamps uncap_level to max 5' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.uncap_level).to eq(5)
end
it 'clamps transcendence_step to max 10' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.transcendence_step).to eq(10)
end
it 'clamps awakening_level to max 10' do
service = described_class.new(user, game_data)
result = service.import
character = result.created.first
expect(character.awakening_level).to eq(10)
end
end
end
describe 'edge cases' do
context 'with nil arousal_level' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 555_555_555,
'evolution' => '4',
'phase' => '0',
'arousal_level' => nil
}
}
]
}
end
it 'defaults awakening_level to 1' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.awakening_level).to eq(1)
end
end
context 'with arousal_level 0' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 444_444_444,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 0
}
}
]
}
end
it 'defaults awakening_level to 1' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.awakening_level).to eq(1)
end
end
context 'with string evolution values' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 333_333_333,
'evolution' => '5',
'phase' => '0',
'arousal_level' => '7'
}
}
]
}
end
it 'handles string values correctly' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.uncap_level).to eq(5)
expect(result.created.first.awakening_level).to eq(7)
end
end
context 'with missing param fields' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 222_222_222
}
}
]
}
end
it 'handles missing fields gracefully (defaults to 0/1)' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
character = result.created.first
expect(character.uncap_level).to eq(0)
expect(character.transcendence_step).to eq(0)
expect(character.awakening_level).to eq(1)
end
end
context 'assigns default awakening via model callback' do
let(:game_data) do
{
'list' => [
{
'master' => { 'id' => '3040171000' },
'param' => {
'id' => 111_111_111,
'evolution' => '4',
'phase' => '0',
'arousal_level' => 5
}
}
]
}
end
it 'has default awakening set by model' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.awakening).to eq(awakening_balanced)
end
end
end
end

View file

@ -0,0 +1,499 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SummonImportService, type: :service do
let(:user) { create(:user) }
# Create summons with specific granblue_ids matching the game data
let(:standard_summon) do
Summon.find_by(granblue_id: '2040035000') ||
create(:summon, granblue_id: '2040035000', name_en: 'Celeste')
end
let(:flb_summon) do
Summon.find_by(granblue_id: '2040445000') ||
create(:summon, granblue_id: '2040445000', name_en: 'Typhon')
end
let(:ulb_summon) do
Summon.find_by(granblue_id: '2040379000') ||
create(:summon, granblue_id: '2040379000', name_en: 'Gorilla')
end
let(:transcendable_summon) do
Summon.find_by(granblue_id: '2040100000') ||
create(:summon, :transcendable, granblue_id: '2040100000', name_en: 'Bahamut')
end
before do
standard_summon
flb_summon
ulb_summon
transcendable_summon
end
describe '#import' do
context 'with valid game data' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 1_500_667_184,
'image_id' => '2040035000',
'level' => '1',
'evolution' => '0',
'phase' => '0'
},
'master' => {
'id' => 2_040_035_000,
'rarity' => '4'
}
}
]
}
end
it 'creates a collection summon' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(1)
expect(result.errors).to be_empty
end
it 'sets the correct game_id' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.game_id).to eq('1500667184')
end
it 'sets the correct uncap_level from evolution' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.uncap_level).to eq(0)
end
it 'associates the correct summon via granblue_id' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.summon.granblue_id).to eq('2040035000')
end
end
context 'with FLB summon (evolution 4)' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 1_499_006_961,
'image_id' => '2040445000',
'level' => '150',
'evolution' => '4',
'phase' => '0'
},
'master' => {
'id' => 2_040_445_000,
'rarity' => '4'
}
}
]
}
end
it 'sets uncap_level to 4' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.uncap_level).to eq(4)
end
end
context 'with ULB summon (evolution 5)' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 1_494_986_603,
'image_id' => '2040379000',
'level' => '200',
'evolution' => '5',
'phase' => '0'
},
'master' => {
'id' => 2_040_379_000,
'rarity' => '4'
}
}
]
}
end
it 'sets uncap_level to 5' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.uncap_level).to eq(5)
end
end
context 'with transcendence data (evolution 6, phase 5)' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 1_088_016_859,
'image_id' => '2040100000_04',
'level' => '250',
'evolution' => '6',
'phase' => '5'
},
'master' => {
'id' => 2_040_100_000,
'rarity' => '4'
}
}
]
}
end
it 'clamps uncap_level to 5 even when evolution is 6' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.uncap_level).to eq(5)
end
it 'sets transcendence_step from phase' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.transcendence_step).to eq(5)
end
end
context 'with duplicate game_id' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 9_999_9999,
'image_id' => '2040035000',
'evolution' => '3',
'phase' => '0'
},
'master' => { 'id' => 2_040_035_000 }
}
]
}
end
before do
create(:collection_summon, user: user, summon: standard_summon, game_id: '99999999')
end
it 'skips the duplicate' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(0)
expect(result.skipped.size).to eq(1)
expect(result.skipped.first[:reason]).to eq('Already exists')
end
context 'with update_existing: true' do
let(:game_data_updated) do
{
'list' => [
{
'param' => {
'id' => 9_999_9999,
'image_id' => '2040035000',
'evolution' => '5',
'phase' => '0'
},
'master' => { 'id' => 2_040_035_000 }
}
]
}
end
it 'updates the existing summon' do
service = described_class.new(user, game_data_updated, update_existing: true)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(0)
expect(result.updated.size).to eq(1)
expect(result.updated.first.uncap_level).to eq(5)
end
end
end
context 'with unknown summon' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 12_345,
'image_id' => '9999999999',
'evolution' => '3',
'phase' => '0'
},
'master' => { 'id' => 9_999_999_999 }
}
]
}
end
it 'records an error for the unknown summon' do
service = described_class.new(user, game_data)
result = service.import
expect(result.errors.size).to eq(1)
expect(result.errors.first[:error]).to eq('Summon not found')
end
end
context 'with multiple summons' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 1_111_1111,
'image_id' => '2040035000',
'evolution' => '0',
'phase' => '0'
},
'master' => { 'id' => 2_040_035_000 }
},
{
'param' => {
'id' => 2_222_2222,
'image_id' => '2040445000',
'evolution' => '4',
'phase' => '0'
},
'master' => { 'id' => 2_040_445_000 }
}
]
}
end
it 'imports all summons' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(2)
end
it 'associates correct summons' do
service = described_class.new(user, game_data)
result = service.import
summons = result.created.sort_by(&:game_id)
expect(summons[0].summon.granblue_id).to eq('2040035000')
expect(summons[1].summon.granblue_id).to eq('2040445000')
end
end
context 'with empty data' do
let(:game_data) { { 'list' => [] } }
it 'returns an error' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be false
expect(result.errors).to include('No summon items found in data')
end
end
context 'with array format data' do
let(:game_data) do
[
{
'param' => {
'id' => 7_777_7777,
'image_id' => '2040035000',
'evolution' => '3',
'phase' => '0'
},
'master' => { 'id' => 2_040_035_000 }
}
]
end
it 'handles array format correctly' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(1)
expect(result.created.first.uncap_level).to eq(3)
end
end
context 'with image_id containing suffix' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 8_888_8888,
'image_id' => '2040100000_04',
'evolution' => '6',
'phase' => '5'
},
'master' => { 'id' => 2_040_100_000 }
}
]
}
end
it 'uses master.id when image_id has suffix' do
service = described_class.new(user, game_data)
result = service.import
# Uses master.id since image_id with _04 suffix won't match
expect(result.created.first.summon.granblue_id).to eq('2040100000')
end
end
context 'with max values' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 6_666_6666,
'image_id' => '2040100000',
'evolution' => '10',
'phase' => '15'
},
'master' => { 'id' => 2_040_100_000 }
}
]
}
end
it 'clamps uncap_level to max 5' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.uncap_level).to eq(5)
end
it 'clamps transcendence_step to max 10' do
service = described_class.new(user, game_data)
result = service.import
summon = result.created.first
expect(summon.transcendence_step).to eq(10)
end
end
end
describe 'edge cases' do
context 'with nil param values' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 5_555_5555,
'image_id' => '2040035000',
'evolution' => nil,
'phase' => nil
},
'master' => { 'id' => 2_040_035_000 }
}
]
}
end
it 'handles nil values gracefully (defaults to 0)' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.uncap_level).to eq(0)
expect(result.created.first.transcendence_step).to eq(0)
end
end
context 'with string evolution values' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 4_444_4444,
'image_id' => '2040035000',
'evolution' => '4',
'phase' => '0'
},
'master' => { 'id' => 2_040_035_000 }
}
]
}
end
it 'handles string evolution values' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.uncap_level).to eq(4)
end
end
context 'with master.id fallback' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => 3_333_3333,
'evolution' => '3',
'phase' => '0'
},
'master' => {
'id' => 2_040_035_000
}
}
]
}
end
it 'uses master.id when param.image_id is missing' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.summon.granblue_id).to eq('2040035000')
end
end
end
end

View file

@ -0,0 +1,671 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe WeaponImportService, type: :service do
let(:user) { create(:user) }
# Create weapons with specific granblue_ids matching the game data
let(:standard_weapon) do
Weapon.find_by(granblue_id: '1040020000') ||
create(:weapon, granblue_id: '1040020000', name_en: 'Luminiera Sword Omega')
end
let(:transcendable_weapon) do
Weapon.find_by(granblue_id: '1040310600') ||
create(:weapon, :transcendable, granblue_id: '1040310600', name_en: 'Yggdrasil Crystal Blade Omega')
end
let(:awakened_weapon) do
Weapon.find_by(granblue_id: '1040914400') ||
create(:weapon, granblue_id: '1040914400', name_en: 'Yamato Katana')
end
let(:ax_weapon) do
Weapon.find_by(granblue_id: '1040213900') ||
create(:weapon, :ax_weapon, granblue_id: '1040213900', name_en: 'Celeste Claw Omega')
end
# Create weapon awakenings
let!(:awakening_atk) do
Awakening.find_by(slug: 'weapon-atk', object_type: 'Weapon') ||
create(:awakening, :for_weapon, slug: 'weapon-atk', name_en: 'Attack')
end
let!(:awakening_def) do
Awakening.find_by(slug: 'weapon-def', object_type: 'Weapon') ||
create(:awakening, :for_weapon, slug: 'weapon-def', name_en: 'Defense')
end
let!(:awakening_special) do
Awakening.find_by(slug: 'weapon-special', object_type: 'Weapon') ||
create(:awakening, :for_weapon, slug: 'weapon-special', name_en: 'Special')
end
let!(:awakening_ca) do
Awakening.find_by(slug: 'weapon-ca', object_type: 'Weapon') ||
create(:awakening, :for_weapon, slug: 'weapon-ca', name_en: 'C.A.')
end
let!(:awakening_skill) do
Awakening.find_by(slug: 'weapon-skill', object_type: 'Weapon') ||
create(:awakening, :for_weapon, slug: 'weapon-skill', name_en: 'Skill DMG')
end
let!(:awakening_heal) do
Awakening.find_by(slug: 'weapon-heal', object_type: 'Weapon') ||
create(:awakening, :for_weapon, slug: 'weapon-heal', name_en: 'Healing')
end
before do
standard_weapon
transcendable_weapon
awakened_weapon
ax_weapon
end
describe '#import' do
context 'with valid game data' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '49858531',
'image_id' => '1040020000',
'evolution' => 3,
'phase' => 0
},
'master' => {
'id' => '1040020000',
'name' => 'Luminiera Sword Omega'
}
}
]
}
end
it 'creates a collection weapon' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(1)
expect(result.errors).to be_empty
end
it 'sets the correct game_id' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.game_id).to eq('49858531')
end
it 'sets the correct uncap_level from evolution' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.uncap_level).to eq(3)
end
it 'associates the correct weapon via granblue_id' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.weapon.granblue_id).to eq('1040020000')
end
end
context 'with transcendence data' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '53169135',
'image_id' => '1040310600',
'evolution' => 5,
'phase' => 6
},
'master' => {
'id' => '1040310600',
'name' => 'Yggdrasil Crystal Blade Omega'
}
}
]
}
end
it 'sets transcendence_step from phase' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.transcendence_step).to eq(6)
end
it 'sets uncap_level to 5 for transcended weapons' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.uncap_level).to eq(5)
end
end
context 'with awakening data' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '96548732',
'image_id' => '1040914400',
'evolution' => 5,
'phase' => 0,
'arousal' => {
'is_arousal_weapon' => true,
'level' => 15,
'form' => 1,
'form_name' => 'Attack'
}
},
'master' => {
'id' => '1040914400',
'name' => 'Yamato Katana'
}
}
]
}
end
it 'sets awakening_id based on form' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.awakening).to eq(awakening_atk)
end
it 'sets awakening_level from arousal level' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.awakening_level).to eq(15)
end
end
context 'with different awakening forms' do
{
1 => 'weapon-atk',
2 => 'weapon-def',
3 => 'weapon-special',
4 => 'weapon-ca',
5 => 'weapon-skill',
6 => 'weapon-heal'
}.each do |form, slug|
it "maps awakening form #{form} to #{slug}" do
game_data = {
'list' => [
{
'param' => {
'id' => "test_#{form}",
'image_id' => '1040914400',
'evolution' => 5,
'phase' => 0,
'arousal' => {
'is_arousal_weapon' => true,
'level' => 10,
'form' => form
}
},
'master' => { 'id' => '1040914400' }
}
]
}
service = described_class.new(user, game_data)
result = service.import
expect(result.created.first.awakening.slug).to eq(slug)
end
end
end
context 'with no awakening' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '12345678',
'image_id' => '1040020000',
'evolution' => 3,
'phase' => 0,
'arousal' => {
'is_arousal_weapon' => false
}
},
'master' => { 'id' => '1040020000' }
}
]
}
end
it 'does not set awakening when is_arousal_weapon is false' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.awakening).to be_nil
expect(weapon.awakening_level).to eq(1) # default value
end
end
context 'with AX skills' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '55555555',
'image_id' => '1040213900',
'evolution' => 5,
'phase' => 0,
'augment_skill_info' => [
[
{
'skill_id' => 1,
'effect_value' => '7',
'show_value' => '7%'
},
{
'skill_id' => 2,
'effect_value' => '2_4',
'show_value' => '4%'
}
]
]
},
'master' => { 'id' => '1040213900' }
}
]
}
end
it 'parses first AX skill correctly' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.ax_modifier1).to eq(1)
expect(weapon.ax_strength1).to eq(7.0)
end
it 'parses second AX skill with underscore format' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.ax_modifier2).to eq(2)
expect(weapon.ax_strength2).to eq(4.0)
end
end
context 'with show_value format for AX strength' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '66666666',
'image_id' => '1040213900',
'evolution' => 5,
'phase' => 0,
'augment_skill_info' => [
[
{
'skill_id' => 3,
'effect_value' => nil,
'show_value' => '5.5%'
}
]
]
},
'master' => { 'id' => '1040213900' }
}
]
}
end
it 'parses strength from show_value when effect_value is nil' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.ax_modifier1).to eq(3)
expect(weapon.ax_strength1).to eq(5.5)
end
end
context 'with duplicate game_id' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '99999999',
'image_id' => '1040020000',
'evolution' => 3,
'phase' => 0
},
'master' => { 'id' => '1040020000' }
}
]
}
end
before do
create(:collection_weapon, user: user, weapon: standard_weapon, game_id: '99999999')
end
it 'skips the duplicate' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(0)
expect(result.skipped.size).to eq(1)
expect(result.skipped.first[:reason]).to eq('Already exists')
end
context 'with update_existing: true' do
let(:game_data_updated) do
{
'list' => [
{
'param' => {
'id' => '99999999',
'image_id' => '1040020000',
'evolution' => 5,
'phase' => 0
},
'master' => { 'id' => '1040020000' }
}
]
}
end
it 'updates the existing weapon' do
service = described_class.new(user, game_data_updated, update_existing: true)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(0)
expect(result.updated.size).to eq(1)
expect(result.updated.first.uncap_level).to eq(5)
end
end
end
context 'with unknown weapon' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '12345',
'image_id' => '9999999999',
'evolution' => 3,
'phase' => 0
},
'master' => { 'id' => '9999999999' }
}
]
}
end
it 'records an error for the unknown weapon' do
service = described_class.new(user, game_data)
result = service.import
expect(result.errors.size).to eq(1)
expect(result.errors.first[:error]).to eq('Weapon not found')
end
end
context 'with multiple weapons' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '11111111',
'image_id' => '1040020000',
'evolution' => 3,
'phase' => 0
},
'master' => { 'id' => '1040020000' }
},
{
'param' => {
'id' => '22222222',
'image_id' => '1040310600',
'evolution' => 5,
'phase' => 3
},
'master' => { 'id' => '1040310600' }
}
]
}
end
it 'imports all weapons' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(2)
end
it 'associates correct weapons' do
service = described_class.new(user, game_data)
result = service.import
weapons = result.created.sort_by(&:game_id)
expect(weapons[0].weapon.granblue_id).to eq('1040020000')
expect(weapons[1].weapon.granblue_id).to eq('1040310600')
end
end
context 'with empty data' do
let(:game_data) { { 'list' => [] } }
it 'returns an error' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be false
expect(result.errors).to include('No weapon items found in data')
end
end
context 'with array format data' do
let(:game_data) do
[
{
'param' => {
'id' => '77777777',
'image_id' => '1040020000',
'evolution' => 4,
'phase' => 0
},
'master' => { 'id' => '1040020000' }
}
]
end
it 'handles array format correctly' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.size).to eq(1)
expect(result.created.first.uncap_level).to eq(4)
end
end
context 'with max uncap/transcendence values' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '88888888',
'image_id' => '1040310600',
'evolution' => 10,
'phase' => 15
},
'master' => { 'id' => '1040310600' }
}
]
}
end
it 'clamps uncap_level to max 5' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.uncap_level).to eq(5)
end
it 'clamps transcendence_step to max 10' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.transcendence_step).to eq(10)
end
end
context 'with max awakening level' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '10101010',
'image_id' => '1040914400',
'evolution' => 5,
'phase' => 0,
'arousal' => {
'is_arousal_weapon' => true,
'level' => 25,
'form' => 1
}
},
'master' => { 'id' => '1040914400' }
}
]
}
end
it 'clamps awakening_level to max 20' do
service = described_class.new(user, game_data)
result = service.import
weapon = result.created.first
expect(weapon.awakening_level).to eq(20)
end
end
context 'with image_id from master.id fallback' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '33333333',
'evolution' => 3,
'phase' => 0
},
'master' => {
'id' => '1040020000'
}
}
]
}
end
it 'uses master.id when param.image_id is missing' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.weapon.granblue_id).to eq('1040020000')
end
end
end
describe 'edge cases' do
context 'with nil arousal' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '44444444',
'image_id' => '1040020000',
'evolution' => 3,
'phase' => 0,
'arousal' => nil
},
'master' => { 'id' => '1040020000' }
}
]
}
end
it 'handles nil arousal gracefully' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.awakening).to be_nil
end
end
context 'with empty augment_skill_info' do
let(:game_data) do
{
'list' => [
{
'param' => {
'id' => '55556666',
'image_id' => '1040213900',
'evolution' => 5,
'phase' => 0,
'augment_skill_info' => []
},
'master' => { 'id' => '1040213900' }
}
]
}
end
it 'handles empty augment_skill_info gracefully' do
service = described_class.new(user, game_data)
result = service.import
expect(result.success?).to be true
expect(result.created.first.ax_modifier1).to be_nil
expect(result.created.first.ax_modifier2).to be_nil
end
end
end
end