Merge branch 'next-main' of https://github.com/jedmund/hensei-api into next-main

This commit is contained in:
Justin Edmund 2025-12-31 22:20:43 -08:00
commit c6d117fc09
51 changed files with 2174 additions and 146 deletions

View file

@ -7,10 +7,28 @@ module Api
:created_at, :updated_at :created_at, :updated_at
field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj| field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj|
[ skills = []
{ modifier: obj.ax_modifier1, strength: obj.ax_strength1 }, if obj.ax_modifier1.present?
{ modifier: obj.ax_modifier2, strength: obj.ax_strength2 } skills << {
].compact_blank modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier1),
strength: obj.ax_strength1
}
end
if obj.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier2),
strength: obj.ax_strength2
}
end
skills
end
field :befoulment, if: ->(_, obj, _) { obj.befoulment_modifier.present? } do |obj|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.befoulment_modifier),
strength: obj.befoulment_strength,
exorcism_level: obj.exorcism_level
}
end end
field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj| field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj|

View file

@ -3,7 +3,7 @@
module Api module Api
module V1 module V1
class GridArtifactBlueprint < ApiBlueprint class GridArtifactBlueprint < ApiBlueprint
fields :level, :reroll_slot fields :level, :reroll_slot, :orphaned
field :collection_artifact_id field :collection_artifact_id
field :out_of_sync, if: ->(_field, ga, _options) { ga.collection_artifact_id.present? } do |ga| field :out_of_sync, if: ->(_field, ga, _options) { ga.collection_artifact_id.present? } do |ga|

View file

@ -3,7 +3,7 @@
module Api module Api
module V1 module V1
class GridSummonBlueprint < ApiBlueprint class GridSummonBlueprint < ApiBlueprint
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step, :orphaned
field :collection_summon_id field :collection_summon_id
field :out_of_sync, if: ->(_field, gs, _options) { gs.collection_summon_id.present? } do |gs| field :out_of_sync, if: ->(_field, gs, _options) { gs.collection_summon_id.present? } do |gs|

View file

@ -3,7 +3,7 @@
module Api module Api
module V1 module V1
class GridWeaponBlueprint < ApiBlueprint class GridWeaponBlueprint < ApiBlueprint
fields :mainhand, :position, :uncap_level, :transcendence_step, :element fields :mainhand, :position, :uncap_level, :transcendence_step, :element, :orphaned
field :collection_weapon_id field :collection_weapon_id
field :out_of_sync, if: ->(_field, gw, _options) { gw.collection_weapon_id.present? } do |gw| field :out_of_sync, if: ->(_field, gw, _options) { gw.collection_weapon_id.present? } do |gw|
@ -15,11 +15,29 @@ module Api
end end
view :nested do view :nested do
field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w| field :ax, if: ->(_field_name, w, _options) { w.ax_modifier1.present? } do |w|
[ skills = []
{ modifier: w.ax_modifier1, strength: w.ax_strength1 }, if w.ax_modifier1.present?
{ modifier: w.ax_modifier2, strength: w.ax_strength2 } skills << {
] modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier1),
strength: w.ax_strength1
}
end
if w.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier2),
strength: w.ax_strength2
}
end
skills
end
field :befoulment, if: ->(_field_name, w, _options) { w.befoulment_modifier.present? } do |w|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(w.befoulment_modifier),
strength: w.befoulment_strength,
exorcism_level: w.exorcism_level
}
end end
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w| field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|

View file

@ -36,6 +36,10 @@ module Api
end end
end end
field :has_orphaned_items do |party|
party.has_orphaned_items?
end
# For collection views # For collection views
view :preview do view :preview do
include_view :preview_objects # Characters, Weapons, Summons include_view :preview_objects # Characters, Weapons, Summons

View file

@ -27,7 +27,7 @@ module Api
}, },
has_weapon_keys: w.weapon_series.has_weapon_keys, has_weapon_keys: w.weapon_series.has_weapon_keys,
has_awakening: w.weapon_series.has_awakening, has_awakening: w.weapon_series.has_awakening,
has_ax_skills: w.weapon_series.has_ax_skills, augment_type: w.weapon_series.augment_type,
extra: w.weapon_series.extra, extra: w.weapon_series.extra,
element_changeable: w.weapon_series.element_changeable element_changeable: w.weapon_series.element_changeable
} }

View file

@ -11,7 +11,7 @@ module Api
end end
fields :slug, :order, :extra, :element_changeable, :has_weapon_keys, fields :slug, :order, :extra, :element_changeable, :has_weapon_keys,
:has_awakening, :has_ax_skills :has_awakening, :augment_type
view :full do view :full do
field :weapon_count do |ws| field :weapon_count do |ws|

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Api
module V1
class WeaponStatModifierBlueprint < Blueprinter::Base
identifier :id
fields :slug, :name_en, :name_jp, :category, :stat, :polarity, :suffix
end
end
end

View file

@ -9,7 +9,7 @@ module Api
before_action :set_collection_artifact_for_read, only: %i[show] before_action :set_collection_artifact_for_read, only: %i[show]
# Write actions: require auth, use current_user # Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import] before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_artifact_for_write, only: %i[update destroy] before_action :set_collection_artifact_for_write, only: %i[update destroy]
def index def index
@ -109,6 +109,8 @@ module Api
# #
# @param data [Hash] Game data containing artifact list # @param data [Hash] Game data containing artifact list
# @param update_existing [Boolean] Whether to update existing artifacts (default: false) # @param update_existing [Boolean] Whether to update existing artifacts (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import def import
game_data = import_params[:data] game_data = import_params[:data]
@ -119,7 +121,10 @@ module Api
service = ArtifactImportService.new( service = ArtifactImportService.new(
current_user, current_user,
game_data, game_data,
update_existing: import_params[:update_existing] == true update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
) )
result = service.import result = service.import
@ -131,10 +136,42 @@ module Api
created: result.created&.size || 0, created: result.created&.size || 0,
updated: result.updated&.size || 0, updated: result.updated&.size || 0,
skipped: result.skipped&.size || 0, skipped: result.skipped&.size || 0,
errors: result.errors || [] errors: result.errors || [],
reconciliation: result.reconciliation
}, status: status }, status: status
end end
# POST /collection/artifacts/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing artifact list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = ArtifactImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |ca|
{
id: ca.id,
game_id: ca.game_id,
name: ca.artifact&.name_en,
granblue_id: ca.artifact&.granblue_id,
element: ca.element,
level: ca.level
}
end,
count: items_to_delete.size
}
end
# DELETE /collection/artifacts/batch_destroy # DELETE /collection/artifacts/batch_destroy
# Deletes multiple collection artifacts in a single request # Deletes multiple collection artifacts in a single request
def batch_destroy def batch_destroy
@ -197,7 +234,10 @@ module Api
def import_params def import_params
{ {
update_existing: params[:update_existing], update_existing: params[:update_existing],
data: params[:data]&.to_unsafe_h is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
} }
end end

View file

@ -7,7 +7,7 @@ module Api
before_action :set_collection_summon_for_read, only: %i[show] before_action :set_collection_summon_for_read, only: %i[show]
# Write actions: require auth, use current_user # Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import] before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_summon_for_write, only: %i[update destroy] before_action :set_collection_summon_for_write, only: %i[update destroy]
def index def index
@ -113,6 +113,8 @@ module Api
# #
# @param data [Hash] Game data containing summon list # @param data [Hash] Game data containing summon list
# @param update_existing [Boolean] Whether to update existing summons (default: false) # @param update_existing [Boolean] Whether to update existing summons (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import def import
game_data = import_params[:data] game_data = import_params[:data]
@ -123,7 +125,10 @@ module Api
service = SummonImportService.new( service = SummonImportService.new(
current_user, current_user,
game_data, game_data,
update_existing: import_params[:update_existing] == true update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
) )
result = service.import result = service.import
@ -135,10 +140,42 @@ module Api
created: result.created.size, created: result.created.size,
updated: result.updated.size, updated: result.updated.size,
skipped: result.skipped.size, skipped: result.skipped.size,
errors: result.errors errors: result.errors,
reconciliation: result.reconciliation
}, status: status }, status: status
end end
# POST /collection/summons/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing summon list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = SummonImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |cs|
{
id: cs.id,
game_id: cs.game_id,
name: cs.summon&.name_en,
granblue_id: cs.summon&.granblue_id,
uncap_level: cs.uncap_level,
transcendence_step: cs.transcendence_step
}
end,
count: items_to_delete.size
}
end
private private
def set_target_user def set_target_user
@ -181,7 +218,10 @@ module Api
def import_params def import_params
{ {
update_existing: params[:update_existing], update_existing: params[:update_existing],
data: params[:data]&.to_unsafe_h is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
} }
end end

View file

@ -7,14 +7,16 @@ module Api
before_action :set_collection_weapon_for_read, only: %i[show] before_action :set_collection_weapon_for_read, only: %i[show]
# Write actions: require auth, use current_user # Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import] before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_weapon_for_write, only: %i[update destroy] before_action :set_collection_weapon_for_write, only: %i[update destroy]
def index def index
@collection_weapons = @target_user.collection_weapons @collection_weapons = @target_user.collection_weapons
.includes(:weapon, :awakening, .includes(:weapon, :awakening,
:weapon_key1, :weapon_key2, :weapon_key1, :weapon_key2,
:weapon_key3, :weapon_key4) :weapon_key3, :weapon_key4,
:ax_modifier1, :ax_modifier2,
:befoulment_modifier)
# Apply filters (array_param splits comma-separated values for OR logic) # Apply filters (array_param splits comma-separated values for OR logic)
@collection_weapons = @collection_weapons.by_weapon(params[:weapon_id]) if params[:weapon_id] @collection_weapons = @collection_weapons.by_weapon(params[:weapon_id]) if params[:weapon_id]
@ -119,6 +121,8 @@ module Api
# #
# @param data [Hash] Game data containing weapon list # @param data [Hash] Game data containing weapon list
# @param update_existing [Boolean] Whether to update existing weapons (default: false) # @param update_existing [Boolean] Whether to update existing weapons (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import def import
game_data = import_params[:data] game_data = import_params[:data]
@ -129,7 +133,10 @@ module Api
service = WeaponImportService.new( service = WeaponImportService.new(
current_user, current_user,
game_data, game_data,
update_existing: import_params[:update_existing] == true update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
) )
result = service.import result = service.import
@ -141,10 +148,42 @@ module Api
created: result.created.size, created: result.created.size,
updated: result.updated.size, updated: result.updated.size,
skipped: result.skipped.size, skipped: result.skipped.size,
errors: result.errors errors: result.errors,
reconciliation: result.reconciliation
}, status: status }, status: status
end end
# POST /collection/weapons/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing weapon list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = WeaponImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |cw|
{
id: cw.id,
game_id: cw.game_id,
name: cw.weapon&.name_en,
granblue_id: cw.weapon&.granblue_id,
uncap_level: cw.uncap_level,
transcendence_step: cw.transcendence_step
}
end,
count: items_to_delete.size
}
end
private private
def set_target_user def set_target_user
@ -177,7 +216,8 @@ module Api
:weapon_id, :uncap_level, :transcendence_step, :weapon_id, :uncap_level, :transcendence_step,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id, :weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
:awakening_id, :awakening_level, :awakening_id, :awakening_level,
:ax_modifier1, :ax_strength1, :ax_modifier2, :ax_strength2, :ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:element :element
) )
end end
@ -187,7 +227,8 @@ module Api
:weapon_id, :uncap_level, :transcendence_step, :weapon_id, :uncap_level, :transcendence_step,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id, :weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
:awakening_id, :awakening_level, :awakening_id, :awakening_level,
:ax_modifier1, :ax_strength1, :ax_modifier2, :ax_strength2, :ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:element :element
]) ])
end end
@ -195,7 +236,10 @@ module Api
def import_params def import_params
{ {
update_existing: params[:update_existing], update_existing: params[:update_existing],
data: params[:data]&.to_unsafe_h is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
} }
end end

View file

@ -291,13 +291,13 @@ module Api
## ##
# Normalizes the AX modifier fields for the weapon parameters. # Normalizes the AX modifier fields for the weapon parameters.
# #
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1. # Sets ax_modifier1_id and ax_modifier2_id to nil if their integer values equal -1.
# #
# @return [void] # @return [void]
def normalize_ax_fields! def normalize_ax_fields!
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1 params[:weapon][:ax_modifier1_id] = nil if weapon_params[:ax_modifier1_id].to_i == -1
params[:weapon][:ax_modifier2_id] = nil if weapon_params[:ax_modifier2_id].to_i == -1
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1 params[:weapon][:befoulment_modifier_id] = nil if weapon_params[:befoulment_modifier_id].to_i == -1
end end
## ##
@ -515,7 +515,8 @@ module Api
:id, :party_id, :weapon_id, :collection_weapon_id, :id, :party_id, :weapon_id, :collection_weapon_id,
:position, :mainhand, :uncap_level, :transcendence_step, :element, :position, :mainhand, :uncap_level, :transcendence_step, :element,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key1_id, :weapon_key2_id, :weapon_key3_id,
:ax_modifier1, :ax_modifier2, :ax_strength1, :ax_strength2, :ax_modifier1_id, :ax_modifier2_id, :ax_strength1, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:awakening_id, :awakening_level :awakening_id, :awakening_level
) )
end end

View file

@ -266,7 +266,10 @@ module Api
awakening: {}, awakening: {},
weapon_key1: {}, weapon_key1: {},
weapon_key2: {}, weapon_key2: {},
weapon_key3: {} weapon_key3: {},
ax_modifier1: {},
ax_modifier2: {},
befoulment_modifier: {}
} }, } },
{ summons: :summon }, { summons: :summon },
:guidebook1, :guidebook2, :guidebook3, :guidebook1, :guidebook2, :guidebook3,
@ -294,7 +297,7 @@ module Api
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength], { ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
earring: %i[modifier strength] }], earring: %i[modifier strength] }],
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step], summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level] weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1_id ax_modifier2_id ax_strength1 ax_strength2 befoulment_modifier_id befoulment_strength exorcism_level awakening_id awakening_level]
) )
end end

View file

@ -68,7 +68,7 @@ module Api
params.require(:weapon_series).permit( params.require(:weapon_series).permit(
:name_en, :name_jp, :slug, :order, :name_en, :name_jp, :slug, :order,
:extra, :element_changeable, :has_weapon_keys, :extra, :element_changeable, :has_weapon_keys,
:has_awakening, :has_ax_skills :has_awakening, :augment_type
) )
end end
end end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Api
module V1
class WeaponStatModifiersController < Api::V1::ApiController
# GET /weapon_stat_modifiers
def index
@modifiers = WeaponStatModifier.all
@modifiers = @modifiers.where(category: params[:category]) if params[:category].present?
render json: WeaponStatModifierBlueprint.render(@modifiers, root: :weapon_stat_modifiers)
end
# GET /weapon_stat_modifiers/:id
def show
@modifier = WeaponStatModifier.find(params[:id])
render json: WeaponStatModifierBlueprint.render(@modifier)
end
end
end
end

View file

@ -7,6 +7,10 @@ class CollectionArtifact < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :artifact belongs_to :artifact
has_many :grid_artifacts, dependent: :nullify
before_destroy :orphan_grid_items
# Enums - using GranblueEnums::ELEMENTS values (excluding Null) # Enums - using GranblueEnums::ELEMENTS values (excluding Null)
# Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 # Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6
enum :element, { enum :element, {
@ -77,4 +81,12 @@ class CollectionArtifact < ApplicationRecord
def quirk_artifact? def quirk_artifact?
artifact&.quirk? artifact&.quirk?
end end
##
# Marks all linked grid artifacts as orphaned before destroying this collection artifact.
#
# @return [void]
def orphan_grid_items
grid_artifacts.update_all(orphaned: true, collection_artifact_id: nil)
end
end end

View file

@ -2,6 +2,10 @@ class CollectionSummon < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :summon belongs_to :summon
has_many :grid_summons, dependent: :nullify
before_destroy :orphan_grid_items
validates :uncap_level, inclusion: { in: 0..5 } validates :uncap_level, inclusion: { in: 0..5 }
validates :transcendence_step, inclusion: { in: 0..10 } validates :transcendence_step, inclusion: { in: 0..10 }
@ -31,4 +35,12 @@ class CollectionSummon < ApplicationRecord
errors.add(:transcendence_step, "not available for this summon") errors.add(:transcendence_step, "not available for this summon")
end end
end end
##
# Marks all linked grid summons as orphaned before destroying this collection summon.
#
# @return [void]
def orphan_grid_items
grid_summons.update_all(orphaned: true, collection_summon_id: nil)
end
end end

View file

@ -8,15 +8,25 @@ class CollectionWeapon < ApplicationRecord
belongs_to :weapon_key3, class_name: 'WeaponKey', optional: true belongs_to :weapon_key3, class_name: 'WeaponKey', optional: true
belongs_to :weapon_key4, class_name: 'WeaponKey', optional: true belongs_to :weapon_key4, class_name: 'WeaponKey', optional: true
belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true
belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true
belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true
has_many :grid_weapons, dependent: :nullify
before_destroy :orphan_grid_items
# Set defaults before validation so database defaults don't cause validation failures # Set defaults before validation so database defaults don't cause validation failures
attribute :awakening_level, :integer, default: 1 attribute :awakening_level, :integer, default: 1
validates :uncap_level, inclusion: { in: 0..5 } validates :uncap_level, inclusion: { in: 0..5 }
validates :transcendence_step, inclusion: { in: 0..10 } validates :transcendence_step, inclusion: { in: 0..10 }
validates :awakening_level, inclusion: { in: 1..20 } validates :awakening_level, inclusion: { in: 1..20 }
validates :exorcism_level, inclusion: { in: 0..5 }, allow_nil: true
validate :validate_weapon_keys validate :validate_weapon_keys
validate :validate_ax_skills validate :validate_ax_skills
validate :validate_befoulment
validate :validate_element_change validate :validate_element_change
validate :validate_awakening_compatibility validate :validate_awakening_compatibility
validate :validate_awakening_level validate :validate_awakening_level
@ -25,7 +35,7 @@ class CollectionWeapon < ApplicationRecord
scope :by_weapon, ->(weapon_id) { where(weapon_id: weapon_id) } scope :by_weapon, ->(weapon_id) { where(weapon_id: weapon_id) }
scope :by_series, ->(series_id) { joins(:weapon).where(weapons: { weapon_series_id: series_id }) } scope :by_series, ->(series_id) { joins(:weapon).where(weapons: { weapon_series_id: series_id }) }
scope :with_keys, -> { where.not(weapon_key1_id: nil) } scope :with_keys, -> { where.not(weapon_key1_id: nil) }
scope :with_ax, -> { where.not(ax_modifier1: nil) } scope :with_ax, -> { where.not(ax_modifier1_id: nil) }
scope :by_element, ->(element) { joins(:weapon).where(weapons: { element: element }) } scope :by_element, ->(element) { joins(:weapon).where(weapons: { element: element }) }
scope :by_rarity, ->(rarity) { joins(:weapon).where(weapons: { rarity: rarity }) } scope :by_rarity, ->(rarity) { joins(:weapon).where(weapons: { rarity: rarity }) }
scope :by_proficiency, ->(proficiency) { joins(:weapon).where(weapons: { proficiency: proficiency }) } scope :by_proficiency, ->(proficiency) { joins(:weapon).where(weapons: { proficiency: proficiency }) }
@ -83,16 +93,43 @@ class CollectionWeapon < ApplicationRecord
end end
def validate_ax_skills def validate_ax_skills
# Check for incomplete AX skills regardless of weapon.ax # AX skill 1: must have both modifier and strength
if (ax_modifier1.present? && ax_strength1.blank?) || if (ax_modifier1.present? && ax_strength1.blank?) ||
(ax_modifier1.blank? && ax_strength1.present?) (ax_modifier1.blank? && ax_strength1.present?)
errors.add(:base, "AX skill 1 must have both modifier and strength") errors.add(:base, "AX skill 1 must have both modifier and strength")
end end
# AX skill 2: must have both modifier and strength
if (ax_modifier2.present? && ax_strength2.blank?) || if (ax_modifier2.present? && ax_strength2.blank?) ||
(ax_modifier2.blank? && ax_strength2.present?) (ax_modifier2.blank? && ax_strength2.present?)
errors.add(:base, "AX skill 2 must have both modifier and strength") errors.add(:base, "AX skill 2 must have both modifier and strength")
end end
# Validate category is 'ax'
if ax_modifier1.present? && ax_modifier1.category != 'ax'
errors.add(:ax_modifier1, "must be an AX skill modifier")
end
if ax_modifier2.present? && ax_modifier2.category != 'ax'
errors.add(:ax_modifier2, "must be an AX skill modifier")
end
end
def validate_befoulment
# Befoulment: must have both modifier and strength
if (befoulment_modifier.present? && befoulment_strength.blank?) ||
(befoulment_modifier.blank? && befoulment_strength.present?)
errors.add(:base, "Befoulment must have both modifier and strength")
end
# Validate category is 'befoulment'
if befoulment_modifier.present? && befoulment_modifier.category != 'befoulment'
errors.add(:befoulment_modifier, "must be a befoulment modifier")
end
# Exorcism level only makes sense with befoulment
if exorcism_level.present? && exorcism_level > 0 && befoulment_modifier.blank?
errors.add(:exorcism_level, "cannot be set without a befoulment")
end
end end
def validate_element_change def validate_element_change
@ -129,4 +166,12 @@ class CollectionWeapon < ApplicationRecord
errors.add(:transcendence_step, "not available for this weapon") if transcendence_step > 0 errors.add(:transcendence_step, "not available for this weapon") if transcendence_step > 0
end end
end end
##
# Marks all linked grid weapons as orphaned before destroying this collection weapon.
#
# @return [void]
def orphan_grid_items
grid_weapons.update_all(orphaned: true, collection_weapon_id: nil)
end
end end

View file

@ -11,6 +11,10 @@ class GridArtifact < ApplicationRecord
has_one :party, through: :grid_character has_one :party, through: :grid_character
has_one :character, through: :grid_character has_one :character, through: :grid_character
# Orphan status scopes
scope :orphaned, -> { where(orphaned: true) }
scope :not_orphaned, -> { where(orphaned: false) }
# Enums - using GranblueEnums::ELEMENTS values (excluding Null) # Enums - using GranblueEnums::ELEMENTS values (excluding Null)
# Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 # Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6
enum :element, { enum :element, {
@ -55,6 +59,14 @@ class GridArtifact < ApplicationRecord
quirk_artifact? ? proficiency : artifact&.proficiency quirk_artifact? ? proficiency : artifact&.proficiency
end end
##
# Marks this grid artifact as orphaned and clears its collection link.
#
# @return [Boolean] true if the update succeeded
def mark_orphaned!
update!(orphaned: true, collection_artifact_id: nil)
end
## ##
# Syncs customizations from the linked collection artifact. # Syncs customizations from the linked collection artifact.
# #

View file

@ -19,6 +19,10 @@ class GridSummon < ApplicationRecord
belongs_to :collection_summon, optional: true belongs_to :collection_summon, optional: true
validates_presence_of :party validates_presence_of :party
# Orphan status scopes
scope :orphaned, -> { where(orphaned: true) }
scope :not_orphaned, -> { where(orphaned: false) }
# Validate that position is provided. # Validate that position is provided.
validates :position, presence: true validates :position, presence: true
validate :compatible_with_position, on: :create validate :compatible_with_position, on: :create
@ -40,6 +44,14 @@ class GridSummon < ApplicationRecord
GridSummonBlueprint GridSummonBlueprint
end end
##
# Marks this grid summon as orphaned and clears its collection link.
#
# @return [Boolean] true if the update succeeded
def mark_orphaned!
update!(orphaned: true, collection_summon_id: nil)
end
## ##
# Syncs customizations from the linked collection summon. # Syncs customizations from the linked collection summon.
# #

View file

@ -39,6 +39,14 @@ class GridWeapon < ApplicationRecord
belongs_to :awakening, optional: true belongs_to :awakening, optional: true
belongs_to :collection_weapon, optional: true belongs_to :collection_weapon, optional: true
belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true
belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true
belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true
# Orphan status scopes
scope :orphaned, -> { where(orphaned: true) }
scope :not_orphaned, -> { where(orphaned: false) }
# Validate that uncap_level is present and numeric, transcendence_step is optional but must be numeric if present. # Validate that uncap_level is present and numeric, transcendence_step is optional but must be numeric if present.
validates :uncap_level, presence: true, numericality: { only_integer: true } validates :uncap_level, presence: true, numericality: { only_integer: true }
validates :transcendence_step, numericality: { only_integer: true }, allow_nil: true validates :transcendence_step, numericality: { only_integer: true }, allow_nil: true
@ -51,10 +59,13 @@ class GridWeapon < ApplicationRecord
##### Amoeba configuration ##### Amoeba configuration
amoeba do amoeba do
nullify :ax_modifier1 nullify :ax_modifier1_id
nullify :ax_modifier2 nullify :ax_modifier2_id
nullify :ax_strength1 nullify :ax_strength1
nullify :ax_strength2 nullify :ax_strength2
nullify :befoulment_modifier_id
nullify :befoulment_strength
nullify :exorcism_level
end end
## ##
@ -80,16 +91,27 @@ class GridWeapon < ApplicationRecord
weapon_key2_id: collection_weapon.weapon_key2_id, weapon_key2_id: collection_weapon.weapon_key2_id,
weapon_key3_id: collection_weapon.weapon_key3_id, weapon_key3_id: collection_weapon.weapon_key3_id,
weapon_key4_id: collection_weapon.weapon_key4_id, weapon_key4_id: collection_weapon.weapon_key4_id,
ax_modifier1: collection_weapon.ax_modifier1, ax_modifier1_id: collection_weapon.ax_modifier1_id,
ax_strength1: collection_weapon.ax_strength1, ax_strength1: collection_weapon.ax_strength1,
ax_modifier2: collection_weapon.ax_modifier2, ax_modifier2_id: collection_weapon.ax_modifier2_id,
ax_strength2: collection_weapon.ax_strength2, ax_strength2: collection_weapon.ax_strength2,
befoulment_modifier_id: collection_weapon.befoulment_modifier_id,
befoulment_strength: collection_weapon.befoulment_strength,
exorcism_level: collection_weapon.exorcism_level,
awakening_id: collection_weapon.awakening_id, awakening_id: collection_weapon.awakening_id,
awakening_level: collection_weapon.awakening_level awakening_level: collection_weapon.awakening_level
) )
true true
end end
##
# Marks this grid weapon as orphaned and clears its collection link.
#
# @return [Boolean] true if the update succeeded
def mark_orphaned!
update!(orphaned: true, collection_weapon_id: nil)
end
## ##
# Checks if grid weapon is out of sync with collection. # Checks if grid weapon is out of sync with collection.
# #
@ -104,10 +126,13 @@ class GridWeapon < ApplicationRecord
weapon_key2_id != collection_weapon.weapon_key2_id || weapon_key2_id != collection_weapon.weapon_key2_id ||
weapon_key3_id != collection_weapon.weapon_key3_id || weapon_key3_id != collection_weapon.weapon_key3_id ||
weapon_key4_id != collection_weapon.weapon_key4_id || weapon_key4_id != collection_weapon.weapon_key4_id ||
ax_modifier1 != collection_weapon.ax_modifier1 || ax_modifier1_id != collection_weapon.ax_modifier1_id ||
ax_strength1 != collection_weapon.ax_strength1 || ax_strength1 != collection_weapon.ax_strength1 ||
ax_modifier2 != collection_weapon.ax_modifier2 || ax_modifier2_id != collection_weapon.ax_modifier2_id ||
ax_strength2 != collection_weapon.ax_strength2 || ax_strength2 != collection_weapon.ax_strength2 ||
befoulment_modifier_id != collection_weapon.befoulment_modifier_id ||
befoulment_strength != collection_weapon.befoulment_strength ||
exorcism_level != collection_weapon.exorcism_level ||
awakening_id != collection_weapon.awakening_id || awakening_id != collection_weapon.awakening_id ||
awakening_level != collection_weapon.awakening_level awakening_level != collection_weapon.awakening_level
end end

View file

@ -274,6 +274,19 @@ class Party < ApplicationRecord
end end
end end
##
# Checks if the party has any orphaned grid items.
#
# An orphaned item is one whose linked collection item has been deleted,
# indicating the user no longer has that item in their game inventory.
#
# @return [Boolean] true if the party has orphaned weapons, summons, or artifacts.
def has_orphaned_items?
weapons.orphaned.exists? ||
summons.orphaned.exists? ||
characters.joins(:grid_artifact).where(grid_artifacts: { orphaned: true }).exists?
end
## ##
# Determines if the party meets the minimum requirements for preview generation. # Determines if the party meets the minimum requirements for preview generation.
# #

View file

@ -5,6 +5,8 @@ class WeaponSeries < ApplicationRecord
has_many :weapon_key_series, dependent: :destroy has_many :weapon_key_series, dependent: :destroy
has_many :weapon_keys, through: :weapon_key_series has_many :weapon_keys, through: :weapon_key_series
enum :augment_type, { none: 0, ax: 1, befoulment: 2 }, default: :none
validates :name_en, presence: true validates :name_en, presence: true
validates :name_jp, presence: true validates :name_jp, presence: true
validates :slug, presence: true, uniqueness: true validates :slug, presence: true, uniqueness: true
@ -15,7 +17,8 @@ class WeaponSeries < ApplicationRecord
scope :element_changeable, -> { where(element_changeable: true) } scope :element_changeable, -> { where(element_changeable: true) }
scope :with_weapon_keys, -> { where(has_weapon_keys: true) } scope :with_weapon_keys, -> { where(has_weapon_keys: true) }
scope :with_awakening, -> { where(has_awakening: true) } scope :with_awakening, -> { where(has_awakening: true) }
scope :with_ax_skills, -> { where(has_ax_skills: true) } scope :with_ax_skills, -> { where(augment_type: :ax) }
scope :with_befoulments, -> { where(augment_type: :befoulment) }
# Slug constants for commonly referenced series # Slug constants for commonly referenced series
DARK_OPUS = 'dark-opus' DARK_OPUS = 'dark-opus'

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
##
# Reference table for weapon stat modifiers (AX skills and befoulments).
#
# AX skills are positive modifiers that can be applied to certain weapons.
# Befoulments are negative modifiers that appear on Odiant weapons.
#
class WeaponStatModifier < ApplicationRecord
CATEGORIES = %w[ax befoulment].freeze
validates :slug, presence: true, uniqueness: true
validates :name_en, presence: true
validates :category, presence: true, inclusion: { in: CATEGORIES }
validates :polarity, inclusion: { in: [-1, 1] }
scope :ax_skills, -> { where(category: 'ax') }
scope :befoulments, -> { where(category: 'befoulment') }
def self.find_by_game_skill_id(id)
find_by(game_skill_id: id.to_i)
end
def buff?
polarity == 1
end
def debuff?
polarity == -1
end
end

View file

@ -12,7 +12,7 @@
# end # end
# #
class ArtifactImportService class ArtifactImportService
Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true)
# Game element values to our element enum values # Game element values to our element enum values
# Game: 1=Fire, 2=Water, 3=Earth, 4=Wind, 5=Light, 6=Dark # Game: 1=Fire, 2=Water, 3=Earth, 4=Wind, 5=Light, 6=Dark
@ -30,10 +30,44 @@ class ArtifactImportService
@user = user @user = user
@game_data = game_data @game_data = game_data
@update_existing = options[:update_existing] || false @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 = [] @created = []
@updated = [] @updated = []
@skipped = [] @skipped = []
@errors = [] @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.
# When a filter is active, only considers items matching that filter.
#
# @return [Array<CollectionArtifact>] 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
# Scoped to filter criteria if present
scope = @user.collection_artifacts
.includes(:artifact)
.where.not(game_id: nil)
.where.not(game_id: game_ids)
scope = apply_filter_scope(scope)
scope
end end
## ##
@ -43,7 +77,14 @@ class ArtifactImportService
def import def import
items = extract_items items = extract_items
if items.empty? if items.empty?
return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No artifact items found in data']) return Result.new(
success?: false,
created: [],
updated: [],
skipped: [],
errors: ['No artifact items found in data'],
reconciliation: nil
)
end end
# Preload artifacts and existing collection artifacts to avoid N+1 queries # Preload artifacts and existing collection artifacts to avoid N+1 queries
@ -58,12 +99,19 @@ class ArtifactImportService
end end
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( Result.new(
success?: @errors.empty?, success?: @errors.empty?,
created: @created, created: @created,
updated: @updated, updated: @updated,
skipped: @skipped, skipped: @skipped,
errors: @errors errors: @errors,
reconciliation: reconciliation_result
) )
end end
@ -92,6 +140,10 @@ class ArtifactImportService
# Handle both string and symbol keys from params # Handle both string and symbol keys from params
data = item.is_a?(Hash) ? item.with_indifferent_access : item 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']) artifact = find_artifact(data['artifact_id'])
unless artifact unless artifact
@errors << { game_id: data['id'], artifact_id: data['artifact_id'], error: 'Artifact not found' } @errors << { game_id: data['id'], artifact_id: data['artifact_id'], error: 'Artifact not found' }
@ -193,4 +245,62 @@ class ArtifactImportService
'level' => level.to_i 'level' => level.to_i
} }
end end
##
# Reconciles deletions by removing collection artifacts 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 artifacts with game_ids NOT in our processed list
# Scoped to filter criteria if present
scope = @user.collection_artifacts
.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_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
##
# Applies element and proficiency filters to a collection artifacts scope.
# Used to scope deletion checks to only items matching the current game filter.
#
# @param scope [ActiveRecord::Relation] The collection artifacts relation to filter
# @return [ActiveRecord::Relation] Filtered relation
def apply_filter_scope(scope)
return scope unless @filter.present?
# Filter by elements if specified
if @filter[:elements].present? || @filter['elements'].present?
elements = @filter[:elements] || @filter['elements']
scope = scope.where(element: elements)
end
# Filter by proficiencies if specified
if @filter[:proficiencies].present? || @filter['proficiencies'].present?
proficiencies = @filter[:proficiencies] || @filter['proficiencies']
scope = scope.where(proficiency: proficiencies)
end
scope
end
end end

View file

@ -41,26 +41,6 @@ module Processors
class WeaponProcessor < BaseProcessor class WeaponProcessor < BaseProcessor
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
# Mapping from ingame AX skill IDs (as strings) to our internal modifier values.
AX_MAPPING = {
'1588' => 2,
'1589' => 0,
'1590' => 1,
'1591' => 3,
'1592' => 4,
'1593' => 9,
'1594' => 13,
'1595' => 10,
'1596' => 5,
'1597' => 6,
'1599' => 8,
'1600' => 12,
'1601' => 11,
'1719' => 15,
'1720' => 16,
'1721' => 17,
'1722' => 14
}.freeze
# KEY_MAPPING maps the raw key value (as a string) to a canonical range or value. # KEY_MAPPING maps the raw key value (as a string) to a canonical range or value.
# For example, in our test we want a raw key "10001" to be interpreted as any key whose # For example, in our test we want a raw key "10001" to be interpreted as any key whose
@ -331,7 +311,8 @@ module Processors
# Processes AX (augment) skill data. # Processes AX (augment) skill data.
# #
# The deck stores AX skills in an array of arrays under "augment_skill_info". # The deck stores AX skills in an array of arrays under "augment_skill_info".
# This method flattens the data and assigns each skills modifier and strength. # This method flattens the data and assigns each skill's modifier and strength.
# Modifiers are now looked up by game_skill_id in the weapon_stat_modifiers table.
# #
# @param grid_weapon [GridWeapon] the grid weapon record being built. # @param grid_weapon [GridWeapon] the grid weapon record being built.
# @param ax_skill_info [Array] the raw AX skill info. # @param ax_skill_info [Array] the raw AX skill info.
@ -340,14 +321,59 @@ module Processors
# Flatten the nested array structure. # Flatten the nested array structure.
ax_skills = ax_skill_info.flatten ax_skills = ax_skill_info.flatten
ax_skills.each_with_index do |ax, idx| ax_skills.each_with_index do |ax, idx|
ax_id = ax['skill_id'].to_s break if idx >= 2 # Only 2 AX skill slots
ax_mod = AX_MAPPING[ax_id] || ax_id.to_i
strength = ax['effect_value'].to_s.gsub(/[+%]/, '').to_i game_skill_id = ax['skill_id'].to_i
grid_weapon["ax_modifier#{idx + 1}"] = ax_mod modifier = find_modifier_by_game_skill_id(game_skill_id)
unless modifier
Rails.logger.warn(
"[WeaponProcessor] Unknown augment skill_id=#{game_skill_id} " \
"icon=#{ax['augment_skill_icon_image']}"
)
next
end
strength = parse_augment_strength(ax['effect_value'], ax['show_value'])
grid_weapon["ax_modifier#{idx + 1}_id"] = modifier.id
grid_weapon["ax_strength#{idx + 1}"] = strength grid_weapon["ax_strength#{idx + 1}"] = strength
end end
end end
##
# Finds a WeaponStatModifier by its game_skill_id.
# Uses memoization to cache lookups.
#
# @param game_skill_id [Integer] the game's skill ID.
# @return [WeaponStatModifier, nil]
def find_modifier_by_game_skill_id(game_skill_id)
@modifier_cache ||= {}
@modifier_cache[game_skill_id] ||= WeaponStatModifier.find_by(game_skill_id: game_skill_id)
end
##
# Parses the strength value from effect_value or show_value.
#
# @param effect_value [String, nil] the effect_value field.
# @param show_value [String, nil] the show_value field.
# @return [Float, nil]
def parse_augment_strength(effect_value, show_value)
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
## ##
# Maps the ingame awakening data (stored under "arousal") to our Awakening record. # Maps the ingame awakening data (stored under "arousal") to our Awakening record.
# #

View file

@ -12,16 +12,49 @@
# end # end
# #
class SummonImportService class SummonImportService
Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true)
def initialize(user, game_data, options = {}) def initialize(user, game_data, options = {})
@user = user @user = user
@game_data = game_data @game_data = game_data
@update_existing = options[:update_existing] || false @update_existing = options[:update_existing] || false
@is_full_inventory = options[:is_full_inventory] || false
@reconcile_deletions = options[:reconcile_deletions] || false
@filter = options[:filter] # { elements: [...] }
@created = [] @created = []
@updated = [] @updated = []
@skipped = [] @skipped = []
@errors = [] @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.
# When a filter is active, only considers items matching that filter.
#
# @return [Array<CollectionSummon>] Collection summons 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 summons with game_ids NOT in the import
# Scoped to filter criteria if present
scope = @user.collection_summons
.includes(:summon)
.where.not(game_id: nil)
.where.not(game_id: game_ids)
scope = apply_filter_scope(scope)
scope
end end
## ##
@ -30,7 +63,16 @@ class SummonImportService
# @return [Result] Import result with counts and errors # @return [Result] Import result with counts and errors
def import def import
items = extract_items items = extract_items
return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No summon items found in data']) if items.empty? if items.empty?
return Result.new(
success?: false,
created: [],
updated: [],
skipped: [],
errors: ['No summon items found in data'],
reconciliation: nil
)
end
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
items.each_with_index do |item, index| items.each_with_index do |item, index|
@ -40,12 +82,19 @@ class SummonImportService
end end
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( Result.new(
success?: @errors.empty?, success?: @errors.empty?,
created: @created, created: @created,
updated: @updated, updated: @updated,
skipped: @skipped, skipped: @skipped,
errors: @errors errors: @errors,
reconciliation: reconciliation_result
) )
end end
@ -68,6 +117,9 @@ class SummonImportService
granblue_id = image_id || master['id'] granblue_id = image_id || master['id']
game_id = param['id'] game_id = param['id']
# Track this game_id as processed (for reconciliation)
@processed_game_ids << game_id.to_s if game_id.present?
summon = find_summon(granblue_id) summon = find_summon(granblue_id)
unless summon unless summon
@errors << { game_id: game_id, granblue_id: granblue_id, error: 'Summon not found' } @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Summon not found' }
@ -143,4 +195,57 @@ class SummonImportService
value = phase.to_i value = phase.to_i
value.clamp(0, 10) value.clamp(0, 10)
end end
##
# Reconciles deletions by removing collection summons 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 summons with game_ids NOT in our processed list
# Scoped to filter criteria if present
scope = @user.collection_summons
.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_summon|
# Collect IDs of grid items that will be orphaned
grid_summon_ids = GridSummon.where(collection_summon_id: coll_summon.id).pluck(:id)
orphaned_grid_item_ids.concat(grid_summon_ids)
# The before_destroy callback on CollectionSummon will mark grid items as orphaned
coll_summon.destroy
deleted_count += 1
end
{
deleted: deleted_count,
orphaned_grid_items: orphaned_grid_item_ids
}
end
##
# Applies element filter to a collection summons scope.
# Used to scope deletion checks to only items matching the current game filter.
#
# @param scope [ActiveRecord::Relation] The collection summons relation to filter
# @return [ActiveRecord::Relation] Filtered relation
def apply_filter_scope(scope)
return scope unless @filter.present?
# Element: always join through summon (no element on collection_summons)
if @filter[:elements].present? || @filter['elements'].present?
elements = @filter[:elements] || @filter['elements']
scope = scope.joins(:summon).where(summons: { element: elements })
end
# Summons don't have proficiency - ignore if present in filter
scope
end
end end

View file

@ -12,7 +12,7 @@
# end # end
# #
class WeaponImportService class WeaponImportService
Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true)
# Game awakening form to our slug mapping # Game awakening form to our slug mapping
AWAKENING_FORM_MAPPING = { AWAKENING_FORM_MAPPING = {
@ -28,11 +28,45 @@ class WeaponImportService
@user = user @user = user
@game_data = game_data @game_data = game_data
@update_existing = options[:update_existing] || false @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 = [] @created = []
@updated = [] @updated = []
@skipped = [] @skipped = []
@errors = [] @errors = []
@awakening_cache = {} @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 end
## ##
@ -41,7 +75,16 @@ class WeaponImportService
# @return [Result] Import result with counts and errors # @return [Result] Import result with counts and errors
def import def import
items = extract_items items = extract_items
return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No weapon items found in data']) if items.empty? 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 ActiveRecord::Base.transaction do
items.each_with_index do |item, index| items.each_with_index do |item, index|
@ -51,12 +94,19 @@ class WeaponImportService
end end
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( Result.new(
success?: @errors.empty?, success?: @errors.empty?,
created: @created, created: @created,
updated: @updated, updated: @updated,
skipped: @skipped, skipped: @skipped,
errors: @errors errors: @errors,
reconciliation: reconciliation_result
) )
end end
@ -77,6 +127,9 @@ class WeaponImportService
granblue_id = param['image_id'] || master['id'] granblue_id = param['image_id'] || master['id']
game_id = param['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) weapon = find_weapon(granblue_id)
unless weapon unless weapon
@errors << { game_id: game_id, granblue_id: granblue_id, error: 'Weapon not found' } @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Weapon not found' }
@ -146,9 +199,18 @@ class WeaponImportService
awakening_attrs = parse_awakening(param['arousal']) awakening_attrs = parse_awakening(param['arousal'])
attrs.merge!(awakening_attrs) if awakening_attrs attrs.merge!(awakening_attrs) if awakening_attrs
# Parse AX skills if present # Check if this is an Odiant (befoulment) weapon
ax_attrs = parse_ax_skills(param['augment_skill_info']) odiant = param['odiant']
attrs.merge!(ax_attrs) if ax_attrs 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 attrs
end end
@ -207,18 +269,18 @@ class WeaponImportService
# First AX skill # First AX skill
if skills[0].is_a?(Hash) if skills[0].is_a?(Hash)
ax1 = parse_single_ax_skill(skills[0]) ax1 = parse_single_augment_skill(skills[0])
if ax1 if ax1
attrs[:ax_modifier1] = ax1[:modifier] attrs[:ax_modifier1_id] = ax1[:modifier_id]
attrs[:ax_strength1] = ax1[:strength] attrs[:ax_strength1] = ax1[:strength]
end end
end end
# Second AX skill # Second AX skill
if skills[1].is_a?(Hash) if skills[1].is_a?(Hash)
ax2 = parse_single_ax_skill(skills[1]) ax2 = parse_single_augment_skill(skills[1])
if ax2 if ax2
attrs[:ax_modifier2] = ax2[:modifier] attrs[:ax_modifier2_id] = ax2[:modifier_id]
attrs[:ax_strength2] = ax2[:strength] attrs[:ax_strength2] = ax2[:strength]
end end
end end
@ -227,26 +289,60 @@ class WeaponImportService
end end
## ##
# Parses a single AX skill from game data. # Parses befoulment data from game format.
# Odiant weapons have a single befoulment in augment_skill_info.
# #
# @param skill [Hash] Single AX skill data with skill_id and effect_value # @param augment_skill_info [Array] The game's augment skill data
# @return [Hash, nil] { modifier:, strength: } or nil # @return [Hash, nil] Befoulment attributes or nil if no befoulment
def parse_single_ax_skill(skill) def parse_befoulment(augment_skill_info)
return nil unless skill['skill_id'].present? return nil if augment_skill_info.blank? || !augment_skill_info.is_a?(Array)
# The skill_id maps to our AX modifier skills = augment_skill_info.first
modifier = skill['skill_id'].to_i return nil if skills.blank? || !skills.is_a?(Array)
# Parse strength from effect_value (may be "3" or "1_3" format) skill = skills.first
# or from show_value (may be "3%" format) return nil unless skill.is_a?(Hash)
strength = parse_ax_strength(skill['effect_value'], skill['show_value'])
return nil unless strength result = parse_single_augment_skill(skill)
return nil unless result
{ modifier: modifier, strength: strength } {
befoulment_modifier_id: result[:modifier_id],
befoulment_strength: result[:strength]
}
end end
def parse_ax_strength(effect_value, show_value) ##
# 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 # Try effect_value first
if effect_value.present? if effect_value.present?
# Handle "1_3" format (seems to be "tier_value") # Handle "1_3" format (seems to be "tier_value")
@ -263,4 +359,66 @@ class WeaponImportService
nil nil
end 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 end

View file

@ -128,6 +128,7 @@ Rails.application.routes.draw do
get 'for_slot/:slot', action: :for_slot, as: :for_slot get 'for_slot/:slot', action: :for_slot, as: :for_slot
end end
end end
resources :weapon_stat_modifiers, only: %i[index show]
# Grid artifacts # Grid artifacts
resources :grid_artifacts, only: %i[create update destroy] do resources :grid_artifacts, only: %i[create update destroy] do
@ -275,6 +276,7 @@ Rails.application.routes.draw do
post :batch post :batch
delete :batch_destroy delete :batch_destroy
post :import post :import
post :preview_sync
end end
end end
resources :summons, only: [:create, :update, :destroy], controller: '/api/v1/collection_summons' do resources :summons, only: [:create, :update, :destroy], controller: '/api/v1/collection_summons' do
@ -282,6 +284,7 @@ Rails.application.routes.draw do
post :batch post :batch
delete :batch_destroy delete :batch_destroy
post :import post :import
post :preview_sync
end end
end end
resources :job_accessories, controller: '/api/v1/collection_job_accessories', resources :job_accessories, controller: '/api/v1/collection_job_accessories',
@ -291,6 +294,7 @@ Rails.application.routes.draw do
post :batch post :batch
delete :batch_destroy delete :batch_destroy
post :import post :import
post :preview_sync
end end
end end
end end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class SeedWeaponStatModifiers < ActiveRecord::Migration[8.0]
def up
ax_skills = [
# Primary AX Skills
{ slug: 'ax_hp', name_en: 'HP', name_jp: 'HP', category: 'ax', stat: 'hp', polarity: 1, suffix: '%', base_min: 1, base_max: 11, game_skill_id: 1588 },
{ slug: 'ax_atk', name_en: 'ATK', name_jp: '攻撃', category: 'ax', stat: 'atk', polarity: 1, suffix: '%', base_min: 1, base_max: 3.5, game_skill_id: 1589 },
{ slug: 'ax_def', name_en: 'DEF', name_jp: '防御', category: 'ax', stat: 'def', polarity: 1, suffix: '%', base_min: 1, base_max: 8, game_skill_id: 1590 },
{ slug: 'ax_ca_dmg', name_en: 'C.A. DMG', name_jp: '奥義ダメ', category: 'ax', stat: 'ca_dmg', polarity: 1, suffix: '%', base_min: 2, base_max: 8.5, game_skill_id: 1591 },
{ slug: 'ax_multiattack', name_en: 'Multiattack Rate', name_jp: '連撃率', category: 'ax', stat: 'multiattack', polarity: 1, suffix: '%', base_min: 1, base_max: 4, game_skill_id: 1592 },
# Secondary AX Skills
{ slug: 'ax_debuff_res', name_en: 'Debuff Resistance', name_jp: '弱体耐性', category: 'ax', stat: 'debuff_res', polarity: 1, suffix: '%', base_min: 1, base_max: 3, game_skill_id: 1593 },
{ slug: 'ax_ele_atk', name_en: 'Elemental ATK', name_jp: '全属性攻撃力', category: 'ax', stat: 'ele_atk', polarity: 1, suffix: '%', base_min: 1, base_max: 5, game_skill_id: 1594 },
{ slug: 'ax_healing', name_en: 'Healing', name_jp: '回復性能', category: 'ax', stat: 'healing', polarity: 1, suffix: '%', base_min: 2, base_max: 5, game_skill_id: 1595 },
{ slug: 'ax_da', name_en: 'Double Attack Rate', name_jp: 'DA確率', category: 'ax', stat: 'da', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1596 },
{ slug: 'ax_ta', name_en: 'Triple Attack Rate', name_jp: 'TA確率', category: 'ax', stat: 'ta', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1597 },
{ slug: 'ax_ca_cap', name_en: 'C.A. DMG Cap', name_jp: '奥義上限', category: 'ax', stat: 'ca_cap', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1599 },
{ slug: 'ax_stamina', name_en: 'Stamina', name_jp: '渾身', category: 'ax', stat: 'stamina', polarity: 1, suffix: nil, base_min: 1, base_max: 3, game_skill_id: 1600 },
{ slug: 'ax_enmity', name_en: 'Enmity', name_jp: '背水', category: 'ax', stat: 'enmity', polarity: 1, suffix: nil, base_min: 1, base_max: 3, game_skill_id: 1601 },
# Extended AX Skills (axType 2)
{ slug: 'ax_skill_supp', name_en: 'Supplemental Skill DMG', name_jp: 'アビ与ダメ上昇', category: 'ax', stat: 'skill_supp', polarity: 1, suffix: nil, base_min: 1, base_max: 5, game_skill_id: 1719 },
{ slug: 'ax_ca_supp', name_en: 'Supplemental C.A. DMG', name_jp: '奥義与ダメ上昇', category: 'ax', stat: 'ca_supp', polarity: 1, suffix: nil, base_min: 1, base_max: 5, game_skill_id: 1720 },
{ slug: 'ax_ele_dmg_red', name_en: 'Elemental DMG Reduction', name_jp: '属性ダメ軽減', category: 'ax', stat: 'ele_dmg_red', polarity: 1, suffix: '%', base_min: 1, base_max: 5, game_skill_id: 1721 },
{ slug: 'ax_na_cap', name_en: 'Normal ATK DMG Cap', name_jp: '通常ダメ上限', category: 'ax', stat: 'na_cap', polarity: 1, suffix: '%', base_min: 0.5, base_max: 1.5, game_skill_id: 1722 },
# Utility AX Skills (axType 3)
{ slug: 'ax_exp', name_en: 'EXP Gain', name_jp: 'EXP UP', category: 'ax', stat: 'exp', polarity: 1, suffix: '%', base_min: 5, base_max: 10, game_skill_id: 1837 },
{ slug: 'ax_rupie', name_en: 'Rupie Gain', name_jp: '獲得ルピ', category: 'ax', stat: 'rupie', polarity: 1, suffix: '%', base_min: 10, base_max: 20, game_skill_id: 1838 }
]
befoulments = [
# Befoulments - game_skill_ids from game data (2873-2881, 2876 doesn't exist)
{ slug: 'befoul_atk_down', name_en: 'ATK Down', name_jp: '攻撃力DOWN', category: 'befoulment', stat: 'atk', polarity: -1, suffix: '%', base_min: -12, base_max: -6, game_skill_id: 2873 },
{ slug: 'befoul_ability_dmg_down', name_en: 'Ability DMG Down', name_jp: 'アビリティダメージDOWN', category: 'befoulment', stat: 'ability_dmg', polarity: -1, suffix: '%', base_min: -50, base_max: -50, game_skill_id: 2874 },
{ slug: 'befoul_ca_dmg_down', name_en: 'CA DMG Down', name_jp: '奥義ダメージDOWN', category: 'befoulment', stat: 'ca_dmg', polarity: -1, suffix: '%', base_min: -38, base_max: -26, game_skill_id: 2875 },
{ slug: 'befoul_da_ta_down', name_en: 'DA/TA Down', name_jp: '連撃率DOWN', category: 'befoulment', stat: 'da_ta', polarity: -1, suffix: '%', base_min: -22, base_max: -19, game_skill_id: 2877 },
{ slug: 'befoul_debuff_down', name_en: 'Debuff Success Down', name_jp: '弱体成功率DOWN', category: 'befoulment', stat: 'debuff_success', polarity: -1, suffix: '%', base_min: -16, base_max: -6, game_skill_id: 2878 },
{ slug: 'befoul_hp_down', name_en: 'Max HP Down', name_jp: '最大HP減少', category: 'befoulment', stat: 'hp', polarity: -1, suffix: '%', base_min: -50, base_max: -26, game_skill_id: 2879 },
{ slug: 'befoul_def_down', name_en: 'DEF Down', name_jp: '防御力DOWN', category: 'befoulment', stat: 'def', polarity: -1, suffix: '%', base_min: -25, base_max: -21, game_skill_id: 2880 },
{ slug: 'befoul_dot', name_en: 'Damage Over Time', name_jp: '毎ターンダメージ', category: 'befoulment', stat: 'dot', polarity: -1, suffix: '%', base_min: 6, base_max: 16, game_skill_id: 2881 }
]
now = Time.current
(ax_skills + befoulments).each do |attrs|
WeaponStatModifier.create!(attrs.merge(created_at: now, updated_at: now))
end
end
def down
WeaponStatModifier.delete_all
end
end

View file

@ -0,0 +1,115 @@
# frozen_string_literal: true
class MigrateAxModifiersToFk < ActiveRecord::Migration[8.0]
# Old AX_MAPPING from WeaponProcessor stored internal integer values (0, 1, 2...)
# We need to map: old internal_value => game_skill_id => weapon_stat_modifier.id
OLD_INTERNAL_TO_GAME_SKILL_ID = {
2 => 1588, # HP
0 => 1589, # ATK
1 => 1590, # DEF
3 => 1591, # C.A. DMG
4 => 1592, # Multiattack Rate
9 => 1593, # Debuff Resistance
13 => 1594, # Elemental ATK
10 => 1595, # Healing
5 => 1596, # Double Attack Rate
6 => 1597, # Triple Attack Rate
8 => 1599, # C.A. DMG Cap
12 => 1600, # Stamina
11 => 1601, # Enmity
15 => 1719, # Supplemental Skill DMG
16 => 1720, # Supplemental C.A. DMG
17 => 1721, # Elemental DMG Reduction
14 => 1722 # Normal ATK DMG Cap
}.freeze
def up
# Build lookup: old_internal_value -> new FK id
modifier_by_game_skill_id = WeaponStatModifier.pluck(:game_skill_id, :id).to_h
old_to_new_id = OLD_INTERNAL_TO_GAME_SKILL_ID.transform_values do |game_skill_id|
modifier_by_game_skill_id[game_skill_id]
end
# Use raw SQL to query old integer columns (ax_modifier1, ax_modifier2)
# and update the new FK columns (ax_modifier1_id, ax_modifier2_id)
# Migrate CollectionWeapon
execute <<-SQL.squish
UPDATE collection_weapons
SET ax_modifier1_id = CASE ax_modifier1
#{old_to_new_id.map { |old_val, new_id| "WHEN #{old_val} THEN #{new_id}" }.join(' ')}
END
WHERE ax_modifier1 IS NOT NULL
SQL
execute <<-SQL.squish
UPDATE collection_weapons
SET ax_modifier2_id = CASE ax_modifier2
#{old_to_new_id.map { |old_val, new_id| "WHEN #{old_val} THEN #{new_id}" }.join(' ')}
END
WHERE ax_modifier2 IS NOT NULL
SQL
# Migrate GridWeapon
execute <<-SQL.squish
UPDATE grid_weapons
SET ax_modifier1_id = CASE ax_modifier1
#{old_to_new_id.map { |old_val, new_id| "WHEN #{old_val} THEN #{new_id}" }.join(' ')}
END
WHERE ax_modifier1 IS NOT NULL
SQL
execute <<-SQL.squish
UPDATE grid_weapons
SET ax_modifier2_id = CASE ax_modifier2
#{old_to_new_id.map { |old_val, new_id| "WHEN #{old_val} THEN #{new_id}" }.join(' ')}
END
WHERE ax_modifier2 IS NOT NULL
SQL
end
def down
# Build reverse lookup: new FK id -> old internal value
modifier_by_game_skill_id = WeaponStatModifier.pluck(:game_skill_id, :id).to_h
game_skill_id_to_internal = OLD_INTERNAL_TO_GAME_SKILL_ID.invert
new_id_to_old = modifier_by_game_skill_id.each_with_object({}) do |(game_skill_id, new_id), hash|
old_val = game_skill_id_to_internal[game_skill_id]
hash[new_id] = old_val if old_val
end
return if new_id_to_old.empty?
# Reverse: copy FK back to old integer columns
execute <<-SQL.squish
UPDATE collection_weapons
SET ax_modifier1 = CASE ax_modifier1_id
#{new_id_to_old.map { |new_id, old_val| "WHEN #{new_id} THEN #{old_val}" }.join(' ')}
END
WHERE ax_modifier1_id IS NOT NULL
SQL
execute <<-SQL.squish
UPDATE collection_weapons
SET ax_modifier2 = CASE ax_modifier2_id
#{new_id_to_old.map { |new_id, old_val| "WHEN #{new_id} THEN #{old_val}" }.join(' ')}
END
WHERE ax_modifier2_id IS NOT NULL
SQL
execute <<-SQL.squish
UPDATE grid_weapons
SET ax_modifier1 = CASE ax_modifier1_id
#{new_id_to_old.map { |new_id, old_val| "WHEN #{new_id} THEN #{old_val}" }.join(' ')}
END
WHERE ax_modifier1_id IS NOT NULL
SQL
execute <<-SQL.squish
UPDATE grid_weapons
SET ax_modifier2 = CASE ax_modifier2_id
#{new_id_to_old.map { |new_id, old_val| "WHEN #{new_id} THEN #{old_val}" }.join(' ')}
END
WHERE ax_modifier2_id IS NOT NULL
SQL
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
class AddBefoulmentGameSkillIds < ActiveRecord::Migration[8.0]
# Befoulment game_skill_id mapping from game data:
# 2873: ex_skill_atk_down | ATK Lowered
# 2874: ex_skill_ab_atk_down | Skill DMG Lowered
# 2875: ex_skill_sp_atk_down | C.A. DMG Lowered
# 2876: (doesn't exist)
# 2877: ex_skill_ta_down | Multiattack Rate Lowered
# 2878: ex_skill_ailment_enhance_down | Debuff Success Rate Lowered
# 2879: ex_skill_hp_down | HP Cut
# 2880: ex_skill_def_down | Def Lowered (already mapped)
# 2881: ex_skill_turn_damage | Turn DMG
BEFOULMENT_GAME_SKILL_IDS = {
'befoul_atk_down' => 2873,
'befoul_ability_dmg_down' => 2874,
'befoul_ca_dmg_down' => 2875,
'befoul_da_ta_down' => 2877,
'befoul_debuff_down' => 2878,
'befoul_hp_down' => 2879,
'befoul_def_down' => 2880, # Already set, but include for completeness
'befoul_dot' => 2881
}.freeze
def up
BEFOULMENT_GAME_SKILL_IDS.each do |slug, game_skill_id|
WeaponStatModifier.where(slug: slug).update_all(game_skill_id: game_skill_id)
end
end
def down
# Clear game_skill_ids for befoulments (except def_down which was already set)
BEFOULMENT_GAME_SKILL_IDS.except('befoul_def_down').each_key do |slug|
WeaponStatModifier.where(slug: slug).update_all(game_skill_id: nil)
end
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20251214193836) DataMigrate::Data.define(version: 20251230000002)

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddOrphanedToGridItems < ActiveRecord::Migration[8.0]
def change
add_column :grid_weapons, :orphaned, :boolean, default: false, null: false
add_column :grid_summons, :orphaned, :boolean, default: false, null: false
add_column :grid_artifacts, :orphaned, :boolean, default: false, null: false
add_index :grid_weapons, :orphaned
add_index :grid_summons, :orphaned
add_index :grid_artifacts, :orphaned
end
end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class CreateWeaponStatModifiers < ActiveRecord::Migration[8.0]
def change
create_table :weapon_stat_modifiers do |t|
t.string :slug, null: false
t.string :name_en, null: false
t.string :name_jp
t.string :category, null: false
t.string :stat
t.integer :polarity, default: 1, null: false
t.string :suffix
t.float :base_min
t.float :base_max
t.integer :game_skill_id
t.timestamps
end
add_index :weapon_stat_modifiers, :slug, unique: true
add_index :weapon_stat_modifiers, :game_skill_id, unique: true
add_index :weapon_stat_modifiers, :category
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class AddWeaponStatModifierFks < ActiveRecord::Migration[8.0]
def change
# collection_weapons - add FK columns
# Note: old ax_modifier1/ax_modifier2 integer columns still exist for data migration
add_column :collection_weapons, :ax_modifier1_id, :bigint
add_column :collection_weapons, :ax_modifier2_id, :bigint
add_column :collection_weapons, :befoulment_modifier_id, :bigint
add_column :collection_weapons, :befoulment_strength, :float
add_column :collection_weapons, :exorcism_level, :integer, default: 0
add_index :collection_weapons, :ax_modifier1_id
add_index :collection_weapons, :ax_modifier2_id
add_index :collection_weapons, :befoulment_modifier_id
add_foreign_key :collection_weapons, :weapon_stat_modifiers, column: :ax_modifier1_id
add_foreign_key :collection_weapons, :weapon_stat_modifiers, column: :ax_modifier2_id
add_foreign_key :collection_weapons, :weapon_stat_modifiers, column: :befoulment_modifier_id
# grid_weapons - same pattern
add_column :grid_weapons, :ax_modifier1_id, :bigint
add_column :grid_weapons, :ax_modifier2_id, :bigint
add_column :grid_weapons, :befoulment_modifier_id, :bigint
add_column :grid_weapons, :befoulment_strength, :float
add_column :grid_weapons, :exorcism_level, :integer, default: 0
add_index :grid_weapons, :ax_modifier1_id
add_index :grid_weapons, :ax_modifier2_id
add_index :grid_weapons, :befoulment_modifier_id
add_foreign_key :grid_weapons, :weapon_stat_modifiers, column: :ax_modifier1_id
add_foreign_key :grid_weapons, :weapon_stat_modifiers, column: :ax_modifier2_id
add_foreign_key :grid_weapons, :weapon_stat_modifiers, column: :befoulment_modifier_id
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class FinalizeAxModifierColumns < ActiveRecord::Migration[8.0]
def change
# Remove old integer columns (data has been migrated to ax_modifier1_id/ax_modifier2_id FKs)
remove_column :collection_weapons, :ax_modifier1, :integer
remove_column :collection_weapons, :ax_modifier2, :integer
remove_column :grid_weapons, :ax_modifier1, :integer
remove_column :grid_weapons, :ax_modifier2, :integer
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class AddAugmentTypeToWeaponSeries < ActiveRecord::Migration[8.0]
def up
add_column :weapon_series, :augment_type, :integer, default: 0, null: false
# Migrate existing has_ax_skills: true to augment_type: 1 (ax)
execute <<-SQL.squish
UPDATE weapon_series
SET augment_type = 1
WHERE has_ax_skills = true
SQL
remove_column :weapon_series, :has_ax_skills
end
def down
add_column :weapon_series, :has_ax_skills, :boolean, default: false, null: false
# Migrate augment_type: 1 (ax) back to has_ax_skills: true
execute <<-SQL.squish
UPDATE weapon_series
SET has_ax_skills = true
WHERE augment_type = 1
SQL
remove_column :weapon_series, :augment_type
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do ActiveRecord::Schema[8.0].define(version: 2025_12_30_000004) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -235,15 +235,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.uuid "weapon_key4_id" t.uuid "weapon_key4_id"
t.uuid "awakening_id" t.uuid "awakening_id"
t.integer "awakening_level", default: 1, null: false t.integer "awakening_level", default: 1, null: false
t.integer "ax_modifier1"
t.float "ax_strength1" t.float "ax_strength1"
t.integer "ax_modifier2"
t.float "ax_strength2" t.float "ax_strength2"
t.integer "element" t.integer "element"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "game_id" t.string "game_id"
t.bigint "ax_modifier1_id"
t.bigint "ax_modifier2_id"
t.bigint "befoulment_modifier_id"
t.float "befoulment_strength"
t.integer "exorcism_level", default: 0
t.index ["awakening_id"], name: "index_collection_weapons_on_awakening_id" t.index ["awakening_id"], name: "index_collection_weapons_on_awakening_id"
t.index ["ax_modifier1_id"], name: "index_collection_weapons_on_ax_modifier1_id"
t.index ["ax_modifier2_id"], name: "index_collection_weapons_on_ax_modifier2_id"
t.index ["befoulment_modifier_id"], name: "index_collection_weapons_on_befoulment_modifier_id"
t.index ["user_id", "game_id"], name: "index_collection_weapons_on_user_id_and_game_id", unique: true, where: "(game_id IS NOT NULL)" t.index ["user_id", "game_id"], name: "index_collection_weapons_on_user_id_and_game_id", unique: true, where: "(game_id IS NOT NULL)"
t.index ["user_id", "weapon_id"], name: "index_collection_weapons_on_user_id_and_weapon_id" t.index ["user_id", "weapon_id"], name: "index_collection_weapons_on_user_id_and_weapon_id"
t.index ["user_id"], name: "index_collection_weapons_on_user_id" t.index ["user_id"], name: "index_collection_weapons_on_user_id"
@ -381,9 +387,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "reroll_slot" t.integer "reroll_slot"
t.uuid "collection_artifact_id" t.uuid "collection_artifact_id"
t.boolean "orphaned", default: false, null: false
t.index ["artifact_id"], name: "index_grid_artifacts_on_artifact_id" t.index ["artifact_id"], name: "index_grid_artifacts_on_artifact_id"
t.index ["collection_artifact_id"], name: "index_grid_artifacts_on_collection_artifact_id" t.index ["collection_artifact_id"], name: "index_grid_artifacts_on_collection_artifact_id"
t.index ["grid_character_id"], name: "index_grid_artifacts_on_grid_character_id", unique: true t.index ["grid_character_id"], name: "index_grid_artifacts_on_grid_character_id", unique: true
t.index ["orphaned"], name: "index_grid_artifacts_on_orphaned"
end end
create_table "grid_characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "grid_characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -422,7 +430,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.integer "transcendence_step", default: 0, null: false t.integer "transcendence_step", default: 0, null: false
t.boolean "quick_summon", default: false t.boolean "quick_summon", default: false
t.uuid "collection_summon_id" t.uuid "collection_summon_id"
t.boolean "orphaned", default: false, null: false
t.index ["collection_summon_id"], name: "index_grid_summons_on_collection_summon_id" t.index ["collection_summon_id"], name: "index_grid_summons_on_collection_summon_id"
t.index ["orphaned"], name: "index_grid_summons_on_orphaned"
t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position" t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_summons_on_party_id" t.index ["party_id"], name: "index_grid_summons_on_party_id"
t.index ["summon_id"], name: "index_grid_summons_on_summon_id" t.index ["summon_id"], name: "index_grid_summons_on_summon_id"
@ -439,9 +449,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.uuid "weapon_key3_id" t.uuid "weapon_key3_id"
t.integer "ax_modifier1"
t.float "ax_strength1" t.float "ax_strength1"
t.integer "ax_modifier2"
t.float "ax_strength2" t.float "ax_strength2"
t.integer "element" t.integer "element"
t.integer "awakening_level", default: 1, null: false t.integer "awakening_level", default: 1, null: false
@ -449,8 +457,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.integer "transcendence_step", default: 0 t.integer "transcendence_step", default: 0
t.string "weapon_key4_id" t.string "weapon_key4_id"
t.uuid "collection_weapon_id" t.uuid "collection_weapon_id"
t.boolean "orphaned", default: false, null: false
t.bigint "ax_modifier1_id"
t.bigint "ax_modifier2_id"
t.bigint "befoulment_modifier_id"
t.float "befoulment_strength"
t.integer "exorcism_level", default: 0
t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id" t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id"
t.index ["ax_modifier1_id"], name: "index_grid_weapons_on_ax_modifier1_id"
t.index ["ax_modifier2_id"], name: "index_grid_weapons_on_ax_modifier2_id"
t.index ["befoulment_modifier_id"], name: "index_grid_weapons_on_befoulment_modifier_id"
t.index ["collection_weapon_id"], name: "index_grid_weapons_on_collection_weapon_id" t.index ["collection_weapon_id"], name: "index_grid_weapons_on_collection_weapon_id"
t.index ["orphaned"], name: "index_grid_weapons_on_orphaned"
t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position" t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_weapons_on_party_id" t.index ["party_id"], name: "index_grid_weapons_on_party_id"
t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id" t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id"
@ -924,7 +942,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.boolean "element_changeable", default: false, null: false t.boolean "element_changeable", default: false, null: false
t.boolean "has_weapon_keys", default: false, null: false t.boolean "has_weapon_keys", default: false, null: false
t.boolean "has_awakening", default: false, null: false t.boolean "has_awakening", default: false, null: false
t.boolean "has_ax_skills", default: false, null: false t.integer "augment_type", default: 0, null: false
t.index ["order"], name: "index_weapon_series_on_order" t.index ["order"], name: "index_weapon_series_on_order"
t.index ["slug"], name: "index_weapon_series_on_slug", unique: true t.index ["slug"], name: "index_weapon_series_on_slug", unique: true
end end
@ -945,6 +963,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
t.index ["weapon_granblue_id"], name: "index_weapon_skills_on_weapon_granblue_id" t.index ["weapon_granblue_id"], name: "index_weapon_skills_on_weapon_granblue_id"
end end
create_table "weapon_stat_modifiers", force: :cascade do |t|
t.string "slug", null: false
t.string "name_en", null: false
t.string "name_jp"
t.string "category", null: false
t.string "stat"
t.integer "polarity", default: 1, null: false
t.string "suffix"
t.float "base_min"
t.float "base_max"
t.integer "game_skill_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["category"], name: "index_weapon_stat_modifiers_on_category"
t.index ["game_skill_id"], name: "index_weapon_stat_modifiers_on_game_skill_id", unique: true
t.index ["slug"], name: "index_weapon_stat_modifiers_on_slug", unique: true
end
create_table "weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name_en" t.string "name_en"
t.string "name_jp" t.string "name_jp"
@ -1024,6 +1060,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key2_id" add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key2_id"
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key3_id" add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key3_id"
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key4_id" add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key4_id"
add_foreign_key "collection_weapons", "weapon_stat_modifiers", column: "ax_modifier1_id"
add_foreign_key "collection_weapons", "weapon_stat_modifiers", column: "ax_modifier2_id"
add_foreign_key "collection_weapons", "weapon_stat_modifiers", column: "befoulment_modifier_id"
add_foreign_key "collection_weapons", "weapons" add_foreign_key "collection_weapons", "weapons"
add_foreign_key "crew_gw_participations", "crews" add_foreign_key "crew_gw_participations", "crews"
add_foreign_key "crew_gw_participations", "gw_events" add_foreign_key "crew_gw_participations", "gw_events"
@ -1049,6 +1088,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_21_210000) do
add_foreign_key "grid_weapons", "collection_weapons" add_foreign_key "grid_weapons", "collection_weapons"
add_foreign_key "grid_weapons", "parties" add_foreign_key "grid_weapons", "parties"
add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id" add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id"
add_foreign_key "grid_weapons", "weapon_stat_modifiers", column: "ax_modifier1_id"
add_foreign_key "grid_weapons", "weapon_stat_modifiers", column: "ax_modifier2_id"
add_foreign_key "grid_weapons", "weapon_stat_modifiers", column: "befoulment_modifier_id"
add_foreign_key "grid_weapons", "weapons" add_foreign_key "grid_weapons", "weapons"
add_foreign_key "gw_crew_scores", "crew_gw_participations" add_foreign_key "gw_crew_scores", "crew_gw_participations"
add_foreign_key "gw_individual_scores", "crew_gw_participations" add_foreign_key "gw_individual_scores", "crew_gw_participations"

View file

@ -1,12 +1,12 @@
id,slug,name_en,name_jp,order,extra,element_changeable,has_weapon_keys,has_awakening,has_ax_skills id,slug,name_en,name_jp,order,extra,element_changeable,has_weapon_keys,has_awakening,augment_type
00000000-0001-0000-0000-000000000001,gacha,Gacha Weapons,ガチャ武器,0,false,false,false,false,false 00000000-0001-0000-0000-000000000001,gacha,Gacha Weapons,ガチャ武器,0,false,false,false,false,none
00000000-0001-0000-0000-000000000002,grand,Grand Weapons,リミテッドシリーズ,2,false,false,true,false,false 00000000-0001-0000-0000-000000000002,grand,Grand Weapons,リミテッドシリーズ,2,false,false,true,false,none
00000000-0001-0000-0000-000000000003,dark-opus,Dark Opus Weapons,終末の神器,3,false,false,true,true,false 00000000-0001-0000-0000-000000000003,dark-opus,Dark Opus Weapons,終末の神器,3,false,false,true,true,none
00000000-0001-0000-0000-000000000004,revenant,Revenant Weapons,天星器,21,false,true,false,false,false 00000000-0001-0000-0000-000000000004,revenant,Revenant Weapons,天星器,21,false,true,false,false,none
00000000-0001-0000-0000-000000000005,xeno,Xeno Weapons,六道武器,23,true,false,false,false,false 00000000-0001-0000-0000-000000000005,xeno,Xeno Weapons,六道武器,23,true,false,false,false,none
00000000-0001-0000-0000-000000000006,ultima,Ultima Weapons,オメガウェポン,9,false,true,true,false,false 00000000-0001-0000-0000-000000000006,ultima,Ultima Weapons,オメガウェポン,9,false,true,true,false,none
00000000-0001-0000-0000-000000000007,superlative,Superlative Weapons,スペリオシリーズ,27,true,true,true,false,false 00000000-0001-0000-0000-000000000007,superlative,Superlative Weapons,スペリオシリーズ,27,true,true,true,false,none
00000000-0001-0000-0000-000000000008,draconic,Draconic Weapons,ドラコニックウェポン・オリジン,5,false,false,true,true,false 00000000-0001-0000-0000-000000000008,draconic,Draconic Weapons,ドラコニックウェポン・オリジン,5,false,false,true,true,none
00000000-0001-0000-0000-000000000009,draconic-providence,Draconic Weapons Providence,ドラコニックウェポン,6,false,false,true,true,false 00000000-0001-0000-0000-000000000009,draconic-providence,Draconic Weapons Providence,ドラコニックウェポン,6,false,false,true,true,none
00000000-0001-0000-0000-000000000010,primal,Primal Weapons,プライマルシリーズ,32,false,false,false,false,false 00000000-0001-0000-0000-000000000010,primal,Primal Weapons,プライマルシリーズ,32,false,false,false,false,none
00000000-0001-0000-0000-000000000011,event,Event Weapons,イベント武器,99,false,false,false,false,false 00000000-0001-0000-0000-000000000011,event,Event Weapons,イベント武器,99,false,false,false,false,none

1 id slug name_en name_jp order extra element_changeable has_weapon_keys has_awakening has_ax_skills augment_type
2 00000000-0001-0000-0000-000000000001 gacha Gacha Weapons ガチャ武器 0 false false false false false none
3 00000000-0001-0000-0000-000000000002 grand Grand Weapons リミテッドシリーズ 2 false false true false false none
4 00000000-0001-0000-0000-000000000003 dark-opus Dark Opus Weapons 終末の神器 3 false false true true false none
5 00000000-0001-0000-0000-000000000004 revenant Revenant Weapons 天星器 21 false true false false false none
6 00000000-0001-0000-0000-000000000005 xeno Xeno Weapons 六道武器 23 true false false false false none
7 00000000-0001-0000-0000-000000000006 ultima Ultima Weapons オメガウェポン 9 false true true false false none
8 00000000-0001-0000-0000-000000000007 superlative Superlative Weapons スペリオシリーズ 27 true true true false false none
9 00000000-0001-0000-0000-000000000008 draconic Draconic Weapons ドラコニックウェポン・オリジン 5 false false true true false none
10 00000000-0001-0000-0000-000000000009 draconic-providence Draconic Weapons Providence ドラコニックウェポン 6 false false true true false none
11 00000000-0001-0000-0000-000000000010 primal Primal Weapons プライマルシリーズ 32 false false false false false none
12 00000000-0001-0000-0000-000000000011 event Event Weapons イベント武器 99 false false false false false none

View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
##
# Seeds for WeaponStatModifier - AX skills and befoulments
#
puts 'Seeding weapon stat modifiers...'
ax_skills = [
# Primary AX Skills
{ slug: 'ax_hp', name_en: 'HP', name_jp: 'HP', category: 'ax', stat: 'hp', polarity: 1, suffix: '%', base_min: 1, base_max: 11, game_skill_id: 1588 },
{ slug: 'ax_atk', name_en: 'ATK', name_jp: '攻撃', category: 'ax', stat: 'atk', polarity: 1, suffix: '%', base_min: 1, base_max: 3.5, game_skill_id: 1589 },
{ slug: 'ax_def', name_en: 'DEF', name_jp: '防御', category: 'ax', stat: 'def', polarity: 1, suffix: '%', base_min: 1, base_max: 8, game_skill_id: 1590 },
{ slug: 'ax_ca_dmg', name_en: 'C.A. DMG', name_jp: '奥義ダメ', category: 'ax', stat: 'ca_dmg', polarity: 1, suffix: '%', base_min: 2, base_max: 8.5, game_skill_id: 1591 },
{ slug: 'ax_multiattack', name_en: 'Multiattack Rate', name_jp: '連撃率', category: 'ax', stat: 'multiattack', polarity: 1, suffix: '%', base_min: 1, base_max: 4, game_skill_id: 1592 },
# Secondary AX Skills
{ slug: 'ax_debuff_res', name_en: 'Debuff Resistance', name_jp: '弱体耐性', category: 'ax', stat: 'debuff_res', polarity: 1, suffix: '%', base_min: 1, base_max: 3, game_skill_id: 1593 },
{ slug: 'ax_ele_atk', name_en: 'Elemental ATK', name_jp: '全属性攻撃力', category: 'ax', stat: 'ele_atk', polarity: 1, suffix: '%', base_min: 1, base_max: 5, game_skill_id: 1594 },
{ slug: 'ax_healing', name_en: 'Healing', name_jp: '回復性能', category: 'ax', stat: 'healing', polarity: 1, suffix: '%', base_min: 2, base_max: 5, game_skill_id: 1595 },
{ slug: 'ax_da', name_en: 'Double Attack Rate', name_jp: 'DA確率', category: 'ax', stat: 'da', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1596 },
{ slug: 'ax_ta', name_en: 'Triple Attack Rate', name_jp: 'TA確率', category: 'ax', stat: 'ta', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1597 },
{ slug: 'ax_ca_cap', name_en: 'C.A. DMG Cap', name_jp: '奥義上限', category: 'ax', stat: 'ca_cap', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1599 },
{ slug: 'ax_stamina', name_en: 'Stamina', name_jp: '渾身', category: 'ax', stat: 'stamina', polarity: 1, suffix: nil, base_min: 1, base_max: 3, game_skill_id: 1600 },
{ slug: 'ax_enmity', name_en: 'Enmity', name_jp: '背水', category: 'ax', stat: 'enmity', polarity: 1, suffix: nil, base_min: 1, base_max: 3, game_skill_id: 1601 },
# Extended AX Skills (axType 2)
{ slug: 'ax_skill_supp', name_en: 'Supplemental Skill DMG', name_jp: 'アビ与ダメ上昇', category: 'ax', stat: 'skill_supp', polarity: 1, suffix: nil, base_min: 1, base_max: 5, game_skill_id: 1719 },
{ slug: 'ax_ca_supp', name_en: 'Supplemental C.A. DMG', name_jp: '奥義与ダメ上昇', category: 'ax', stat: 'ca_supp', polarity: 1, suffix: nil, base_min: 1, base_max: 5, game_skill_id: 1720 },
{ slug: 'ax_ele_dmg_red', name_en: 'Elemental DMG Reduction', name_jp: '属性ダメ軽減', category: 'ax', stat: 'ele_dmg_red', polarity: 1, suffix: '%', base_min: 1, base_max: 5, game_skill_id: 1721 },
{ slug: 'ax_na_cap', name_en: 'Normal ATK DMG Cap', name_jp: '通常ダメ上限', category: 'ax', stat: 'na_cap', polarity: 1, suffix: '%', base_min: 0.5, base_max: 1.5, game_skill_id: 1722 },
# Utility AX Skills (axType 3)
{ slug: 'ax_exp', name_en: 'EXP Gain', name_jp: 'EXP UP', category: 'ax', stat: 'exp', polarity: 1, suffix: '%', base_min: 5, base_max: 10, game_skill_id: 1837 },
{ slug: 'ax_rupie', name_en: 'Rupie Gain', name_jp: '獲得ルピ', category: 'ax', stat: 'rupie', polarity: 1, suffix: '%', base_min: 10, base_max: 20, game_skill_id: 1838 }
]
befoulments = [
# Befoulments - game_skill_ids from game data (2873-2881, 2876 doesn't exist)
{ slug: 'befoul_atk_down', name_en: 'ATK Down', name_jp: '攻撃力DOWN', category: 'befoulment', stat: 'atk', polarity: -1, suffix: '%', base_min: -12, base_max: -6, game_skill_id: 2873 },
{ slug: 'befoul_ability_dmg_down', name_en: 'Ability DMG Down', name_jp: 'アビリティダメージDOWN', category: 'befoulment', stat: 'ability_dmg', polarity: -1, suffix: '%', base_min: -50, base_max: -50, game_skill_id: 2874 },
{ slug: 'befoul_ca_dmg_down', name_en: 'CA DMG Down', name_jp: '奥義ダメージDOWN', category: 'befoulment', stat: 'ca_dmg', polarity: -1, suffix: '%', base_min: -38, base_max: -26, game_skill_id: 2875 },
{ slug: 'befoul_da_ta_down', name_en: 'DA/TA Down', name_jp: '連撃率DOWN', category: 'befoulment', stat: 'da_ta', polarity: -1, suffix: '%', base_min: -22, base_max: -19, game_skill_id: 2877 },
{ slug: 'befoul_debuff_down', name_en: 'Debuff Success Down', name_jp: '弱体成功率DOWN', category: 'befoulment', stat: 'debuff_success', polarity: -1, suffix: '%', base_min: -16, base_max: -6, game_skill_id: 2878 },
{ slug: 'befoul_hp_down', name_en: 'Max HP Down', name_jp: '最大HP減少', category: 'befoulment', stat: 'hp', polarity: -1, suffix: '%', base_min: -50, base_max: -26, game_skill_id: 2879 },
{ slug: 'befoul_def_down', name_en: 'DEF Down', name_jp: '防御力DOWN', category: 'befoulment', stat: 'def', polarity: -1, suffix: '%', base_min: -25, base_max: -21, game_skill_id: 2880 },
{ slug: 'befoul_dot', name_en: 'Damage Over Time', name_jp: '毎ターンダメージ', category: 'befoulment', stat: 'dot', polarity: -1, suffix: '%', base_min: 6, base_max: 16, game_skill_id: 2881 }
]
(ax_skills + befoulments).each do |attrs|
modifier = WeaponStatModifier.find_or_initialize_by(slug: attrs[:slug])
modifier.assign_attributes(attrs)
modifier.save!
end
puts "Created/updated #{WeaponStatModifier.count} weapon stat modifiers"

View file

@ -0,0 +1,671 @@
# Befoulments & WeaponStatModifier Consolidation
## Overview
Implement Befoulments (魔蝕) and consolidate with AX skills using a new `WeaponStatModifier` table.
### Terminology
- **Odiant (禁禍武器)** = A weapon series that has befoulments instead of AX skills
- **Befoulments (魔蝕)** = Negative stat augments that appear on Odiant weapons
- **Exorcism Level (退魔Lv)** = Level 0-5 that reduces befoulment strength
**Key insight:** `augment_skill_info` contains:
- **AX skills** for regular weapons (`is_odiant_weapon: false`)
- **Befoulments** for Odiant weapons (`is_odiant_weapon: true`)
They are mutually exclusive - a weapon has either AX skills OR befoulments, never both.
---
## Database Schema
### 1. New Table: `weapon_stat_modifiers`
Centralized definition of all weapon stat modifiers (AX skills + befoulments):
```ruby
create_table :weapon_stat_modifiers do |t|
t.string :slug, null: false, index: { unique: true } # 'ax_atk', 'befoul_def_down'
t.string :name_en, null: false
t.string :name_jp
t.string :category, null: false # 'ax' or 'befoulment'
t.string :stat # 'atk', 'def', 'da_ta', 'ca_dmg', 'hp', 'dot', etc.
t.integer :polarity, default: 1 # 1 = buff, -1 = debuff
t.string :suffix # '%' for percentages, nil for flat values
t.float :base_min # Known min initial value (informational)
t.float :base_max # Known max initial value (informational)
t.integer :game_skill_id # Maps to game's skill_id for import
t.timestamps
end
```
**Icon convention:** Frontend derives icon path from `slug`:
- `ax_atk``weapon-stat-modifier/ax_atk.png`
- `befoul_def_down``weapon-stat-modifier/befoul_def_down.png`
Rename game icons (e.g., `ex_skill_def_down``befoul_def_down`) to match slugs.
### 2. Refactor AX & Add Befoulment Columns (Foreign Keys)
Replace raw integer `ax_modifier1/2` with foreign keys, and add befoulment FK:
```ruby
# collection_weapons
# Remove old integer columns
remove_column :collection_weapons, :ax_modifier1, :integer
remove_column :collection_weapons, :ax_modifier2, :integer
# Add FK columns referencing weapon_stat_modifiers
add_reference :collection_weapons, :ax_modifier1, foreign_key: { to_table: :weapon_stat_modifiers }
add_reference :collection_weapons, :ax_modifier2, foreign_key: { to_table: :weapon_stat_modifiers }
add_reference :collection_weapons, :befoulment_modifier, foreign_key: { to_table: :weapon_stat_modifiers }
add_column :collection_weapons, :befoulment_strength, :float
add_column :collection_weapons, :exorcism_level, :integer, default: 0
# grid_weapons - same pattern
add_reference :grid_weapons, :ax_modifier1, foreign_key: { to_table: :weapon_stat_modifiers }
add_reference :grid_weapons, :ax_modifier2, foreign_key: { to_table: :weapon_stat_modifiers }
add_reference :grid_weapons, :befoulment_modifier, foreign_key: { to_table: :weapon_stat_modifiers }
add_column :grid_weapons, :befoulment_strength, :float
add_column :grid_weapons, :exorcism_level, :integer, default: 0
```
### 2b. Data Migration for Existing AX Skills
Migrate existing `ax_modifier1/2` integer values to FK references:
```ruby
# Run after weapon_stat_modifiers table is seeded
CollectionWeapon.where.not(ax_modifier1: nil).find_each do |cw|
modifier = WeaponStatModifier.find_by(game_skill_id: cw.ax_modifier1)
cw.update_columns(ax_modifier1_id: modifier&.id) if modifier
end
CollectionWeapon.where.not(ax_modifier2: nil).find_each do |cw|
modifier = WeaponStatModifier.find_by(game_skill_id: cw.ax_modifier2)
cw.update_columns(ax_modifier2_id: modifier&.id) if modifier
end
# Same for GridWeapon
```
**Note:** Strength values (`ax_strength1/2`) are preserved as-is since they store the actual value.
### 3. WeaponSeries `augment_type` Enum
Replace `has_ax_skills` boolean with an enum that enforces mutual exclusivity:
```ruby
# Migration
remove_column :weapon_series, :has_ax_skills, :boolean
add_column :weapon_series, :augment_type, :integer, default: 0
# Data migration
WeaponSeries.where(has_ax_skills: true).update_all(augment_type: 1)
```
```ruby
# Model
enum :augment_type, { none: 0, ax: 1, befoulment: 2 }, default: :none
scope :with_ax_skills, -> { where(augment_type: :ax) }
scope :with_befoulments, -> { where(augment_type: :befoulment) }
```
---
## Seed Data
### AX Skill Modifiers (Complete List)
All AX skills from game data with their game_skill_id values:
```ruby
WeaponStatModifier.create!([
# Primary AX Skills
{ slug: 'ax_atk', name_en: 'ATK', name_jp: '攻撃', category: 'ax', stat: 'atk', polarity: 1, suffix: '%', base_min: 1, base_max: 3.5, game_skill_id: 1589 },
{ slug: 'ax_def', name_en: 'DEF', name_jp: '防御', category: 'ax', stat: 'def', polarity: 1, suffix: '%', base_min: 1, base_max: 8, game_skill_id: 1590 },
{ slug: 'ax_hp', name_en: 'HP', name_jp: 'HP', category: 'ax', stat: 'hp', polarity: 1, suffix: '%', base_min: 1, base_max: 11, game_skill_id: 1588 },
{ slug: 'ax_ca_dmg', name_en: 'C.A. DMG', name_jp: '奥義ダメ', category: 'ax', stat: 'ca_dmg', polarity: 1, suffix: '%', base_min: 2, base_max: 8.5, game_skill_id: 1591 },
{ slug: 'ax_multiattack', name_en: 'Multiattack Rate', name_jp: '連撃率', category: 'ax', stat: 'multiattack', polarity: 1, suffix: '%', base_min: 1, base_max: 4, game_skill_id: 1592 },
# Secondary AX Skills
{ slug: 'ax_debuff_res', name_en: 'Debuff Resistance', name_jp: '弱体耐性', category: 'ax', stat: 'debuff_res', polarity: 1, suffix: '%', base_min: 1, base_max: 3, game_skill_id: 1593 },
{ slug: 'ax_ele_atk', name_en: 'Elemental ATK', name_jp: '全属性攻撃力', category: 'ax', stat: 'ele_atk', polarity: 1, suffix: '%', base_min: 1, base_max: 5, game_skill_id: 1594 },
{ slug: 'ax_healing', name_en: 'Healing', name_jp: '回復性能', category: 'ax', stat: 'healing', polarity: 1, suffix: '%', base_min: 2, base_max: 5, game_skill_id: 1595 },
{ slug: 'ax_da', name_en: 'Double Attack Rate', name_jp: 'DA確率', category: 'ax', stat: 'da', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1596 },
{ slug: 'ax_ta', name_en: 'Triple Attack Rate', name_jp: 'TA確率', category: 'ax', stat: 'ta', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1597 },
{ slug: 'ax_ca_cap', name_en: 'C.A. DMG Cap', name_jp: '奥義上限', category: 'ax', stat: 'ca_cap', polarity: 1, suffix: '%', base_min: 1, base_max: 2, game_skill_id: 1599 },
{ slug: 'ax_stamina', name_en: 'Stamina', name_jp: '渾身', category: 'ax', stat: 'stamina', polarity: 1, suffix: nil, base_min: 1, base_max: 3, game_skill_id: 1600 },
{ slug: 'ax_enmity', name_en: 'Enmity', name_jp: '背水', category: 'ax', stat: 'enmity', polarity: 1, suffix: nil, base_min: 1, base_max: 3, game_skill_id: 1601 },
# Extended AX Skills (axType 2)
{ slug: 'ax_skill_supp', name_en: 'Supplemental Skill DMG', name_jp: 'アビ与ダメ上昇', category: 'ax', stat: 'skill_supp', polarity: 1, suffix: nil, base_min: 1, base_max: 5, game_skill_id: 1719 },
{ slug: 'ax_ca_supp', name_en: 'Supplemental C.A. DMG', name_jp: '奥義与ダメ上昇', category: 'ax', stat: 'ca_supp', polarity: 1, suffix: nil, base_min: 1, base_max: 5, game_skill_id: 1720 },
{ slug: 'ax_ele_dmg_red', name_en: 'Elemental DMG Reduction', name_jp: '属性ダメ軽減', category: 'ax', stat: 'ele_dmg_red', polarity: 1, suffix: '%', base_min: 1, base_max: 5, game_skill_id: 1721 },
{ slug: 'ax_na_cap', name_en: 'Normal ATK DMG Cap', name_jp: '通常ダメ上限', category: 'ax', stat: 'na_cap', polarity: 1, suffix: '%', base_min: 0.5, base_max: 1.5, game_skill_id: 1722 },
# Utility AX Skills (axType 3)
{ slug: 'ax_exp', name_en: 'EXP Gain', name_jp: 'EXP UP', category: 'ax', stat: 'exp', polarity: 1, suffix: '%', base_min: 5, base_max: 10, game_skill_id: 1837 },
{ slug: 'ax_rupie', name_en: 'Rupie Gain', name_jp: '獲得ルピ', category: 'ax', stat: 'rupie', polarity: 1, suffix: '%', base_min: 10, base_max: 20, game_skill_id: 1838 },
])
```
### Befoulment Modifiers
```ruby
# game_skill_id values will be populated as we discover them - we know 2880 = DEF Down
WeaponStatModifier.create!([
{ slug: 'befoul_atk_down', name_en: 'ATK Down', name_jp: '攻撃力DOWN', category: 'befoulment', stat: 'atk', polarity: -1, suffix: '%', base_min: -12, base_max: -6 },
{ slug: 'befoul_def_down', name_en: 'DEF Down', name_jp: '防御力DOWN', category: 'befoulment', stat: 'def', polarity: -1, suffix: '%', base_min: -25, base_max: -21, game_skill_id: 2880 },
{ slug: 'befoul_da_ta_down', name_en: 'DA/TA Down', name_jp: '連撃率DOWN', category: 'befoulment', stat: 'da_ta', polarity: -1, suffix: '%', base_min: -22, base_max: -19 },
{ slug: 'befoul_ca_dmg_down', name_en: 'CA DMG Down', name_jp: '奥義ダメージDOWN', category: 'befoulment', stat: 'ca_dmg', polarity: -1, suffix: '%', base_min: -38, base_max: -26 },
{ slug: 'befoul_dot', name_en: 'Damage Over Time', name_jp: '毎ターンダメージ', category: 'befoulment', stat: 'dot', polarity: -1, suffix: '%', base_min: 6, base_max: 16 },
{ slug: 'befoul_hp_down', name_en: 'Max HP Down', name_jp: '最大HP減少', category: 'befoulment', stat: 'hp', polarity: -1, suffix: '%', base_min: -50, base_max: -26 },
{ slug: 'befoul_debuff_down', name_en: 'Debuff Success Down', name_jp: '弱体成功率DOWN', category: 'befoulment', stat: 'debuff_success', polarity: -1, suffix: '%', base_min: -16, base_max: -6 },
{ slug: 'befoul_ability_dmg_down', name_en: 'Ability DMG Down', name_jp: 'アビリティダメージDOWN', category: 'befoulment', stat: 'ability_dmg', polarity: -1, suffix: '%', base_min: -50, base_max: -50 },
])
```
---
## Model Changes
### WeaponStatModifier Model
```ruby
# app/models/weapon_stat_modifier.rb
class WeaponStatModifier < ApplicationRecord
validates :slug, presence: true, uniqueness: true
validates :name_en, presence: true
validates :category, presence: true, inclusion: { in: %w[ax befoulment] }
validates :polarity, inclusion: { in: [-1, 1] }
scope :ax_skills, -> { where(category: 'ax') }
scope :befoulments, -> { where(category: 'befoulment') }
def self.find_by_game_skill_id(id)
find_by(game_skill_id: id.to_i)
end
end
```
### CollectionWeapon Changes
```ruby
# app/models/collection_weapon.rb
# Replace old integer references with proper associations
belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true
belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true
belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true
validates :exorcism_level, numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 5
}, allow_nil: true
validate :validate_ax_skills
validate :validate_befoulment_fields
def validate_ax_skills
# AX skill 1: must have both modifier and strength
if (ax_modifier1.present? && ax_strength1.blank?) ||
(ax_modifier1.blank? && ax_strength1.present?)
errors.add(:base, "AX skill 1 must have both modifier and strength")
end
# AX skill 2: must have both modifier and strength
if (ax_modifier2.present? && ax_strength2.blank?) ||
(ax_modifier2.blank? && ax_strength2.present?)
errors.add(:base, "AX skill 2 must have both modifier and strength")
end
# Validate category is 'ax'
if ax_modifier1.present? && ax_modifier1.category != 'ax'
errors.add(:ax_modifier1, "must be an AX skill modifier")
end
if ax_modifier2.present? && ax_modifier2.category != 'ax'
errors.add(:ax_modifier2, "must be an AX skill modifier")
end
end
def validate_befoulment_fields
if (befoulment_modifier.present? && befoulment_strength.blank?) ||
(befoulment_modifier.blank? && befoulment_strength.present?)
errors.add(:base, "Befoulment must have both modifier and strength")
end
# Validate category is 'befoulment'
if befoulment_modifier.present? && befoulment_modifier.category != 'befoulment'
errors.add(:befoulment_modifier, "must be a befoulment modifier")
end
end
```
### GridWeapon Changes
```ruby
# app/models/grid_weapon.rb
# Same associations as CollectionWeapon
belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true
belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true
belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true
# Same validations as CollectionWeapon
```
- Update `sync_from_collection!` to include befoulment fields
- Update `out_of_sync?` to check befoulment fields
- Update Amoeba config to nullify befoulment fields on copy
---
## API Changes
### Blueprint Serialization
```ruby
# collection_weapon_blueprint.rb
# AX skills - now with full modifier object
field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj|
skills = []
if obj.ax_modifier1.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier1),
strength: obj.ax_strength1
}
end
if obj.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier2),
strength: obj.ax_strength2
}
end
skills
end
# Befoulment - with full modifier object
field :befoulment, if: ->(_, obj, _) { obj.befoulment_modifier.present? } do |obj|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.befoulment_modifier),
strength: obj.befoulment_strength,
exorcism_level: obj.exorcism_level
}
end
# grid_weapon_blueprint.rb - similar
```
### WeaponStatModifierBlueprint
```ruby
# app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb
class Api::V1::WeaponStatModifierBlueprint < Blueprinter::Base
identifier :id
fields :slug, :name_en, :name_jp, :category, :stat, :polarity, :suffix
end
```
### New Endpoint: GET /weapon_stat_modifiers
Return all weapon stat modifiers for frontend reference:
```ruby
# app/controllers/api/v1/weapon_stat_modifiers_controller.rb
def index
@modifiers = WeaponStatModifier.all
render json: WeaponStatModifierBlueprint.render(@modifiers, root: :weapon_stat_modifiers)
end
```
### Controller Params
Update `weapon_params` in both controllers:
```ruby
# Replace :ax_modifier1, :ax_modifier2 with FK versions
:ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level
```
---
## Import Service
### Game JSON Structure
**Odiant weapon (with befoulment):**
```json
{
"augment_skill_info": [[{ "skill_id": 2880, "effect_value": "25", "show_value": "-25%" }]],
"odiant": {
"is_odiant_weapon": true,
"exorcision_level": 1,
"max_exorcision_level": 5
}
}
```
**Regular weapon (with AX skills):**
```json
{
"augment_skill_info": [[
{ "skill_id": 1589, "effect_value": "3", "show_value": "+3%" },
{ "skill_id": 1719, "effect_value": "1_2000", "show_value": "+3" }
]],
"odiant": {
"is_odiant_weapon": false,
"exorcision_level": 0
}
}
```
**Key insight:** Same `augment_skill_info` structure, differentiated by `is_odiant_weapon`:
- `true` → parse as befoulment (skill_id 2880+)
- `false` → parse as AX skills (skill_id 1588-1722)
### WeaponImportService Updates
Add a cache for WeaponStatModifier lookups:
```ruby
def initialize(user, game_data, options = {})
# ... existing code ...
@modifier_cache = {} # Cache for WeaponStatModifier lookups
end
def build_collection_weapon_attrs(item, weapon)
param = item['param'] || {}
attrs = {
weapon: weapon,
game_id: param['id'].to_s,
# ... existing 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 = parse_befoulment(param['augment_skill_info'])
if befoulment
attrs[:befoulment_modifier_id] = befoulment[:modifier_id]
attrs[:befoulment_strength] = befoulment[:strength]
end
attrs[:exorcism_level] = odiant['exorcision_level'].to_i
else
# Regular weapon - parse AX skills
ax_attrs = parse_ax_skills(param['augment_skill_info'])
attrs.merge!(ax_attrs) if ax_attrs
end
# ... rest of existing code (awakening, etc) ...
attrs
end
# Updated to return FK id instead of raw game_skill_id
def parse_ax_skills(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)
attrs = {}
# First AX skill
if skills[0].is_a?(Hash)
ax1 = parse_single_ax_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_ax_skill(skills[1])
if ax2
attrs[:ax_modifier2_id] = ax2[:modifier_id]
attrs[:ax_strength2] = ax2[:strength]
end
end
attrs.empty? ? nil : attrs
end
def parse_single_ax_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)
return nil unless modifier
strength = parse_ax_strength(skill['effect_value'], skill['show_value'])
return nil unless strength
{ modifier_id: modifier.id, strength: strength }
end
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) && skill['skill_id'].present?
game_skill_id = skill['skill_id'].to_i
modifier = find_modifier_by_game_skill_id(game_skill_id)
return nil unless modifier
{
modifier_id: modifier.id,
strength: parse_befoulment_strength(skill['effect_value'], skill['show_value'])
}
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_befoulment_strength(effect_value, show_value)
# show_value has the sign: "-25%"
# effect_value is unsigned: "25"
if show_value.present?
show_value.to_s.gsub('%', '').to_f
elsif effect_value.present?
-effect_value.to_f
end
end
```
**Note:** Unknown `game_skill_id` values will be skipped (modifier not found). This is acceptable - we can add new modifiers to the seed data as we discover them.
---
## Files to Create/Modify
| File | Action |
|------|--------|
| **Migrations** | |
| `db/migrate/xxx_create_weapon_stat_modifiers.rb` | Create - new reference table |
| `db/migrate/xxx_refactor_ax_and_add_befoulments.rb` | Create - replace ax_modifier integers with FKs, add befoulment FKs |
| `db/migrate/xxx_replace_has_ax_skills_with_augment_type.rb` | Create - enum migration with data migration |
| `db/data/xxx_migrate_ax_modifiers_to_fk.rb` | Create - data migration to convert existing ax_modifier values to FK references |
| **Models** | |
| `app/models/weapon_stat_modifier.rb` | Create |
| `app/models/collection_weapon.rb` | Modify - add befoulment validation |
| `app/models/grid_weapon.rb` | Modify - add befoulment fields, sync |
| `app/models/weapon_series.rb` | Modify - replace has_ax_skills with augment_type enum |
| **Blueprints** | |
| `app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb` | Create |
| `app/blueprints/api/v1/collection_weapon_blueprint.rb` | Modify - add befoulment serialization |
| `app/blueprints/api/v1/grid_weapon_blueprint.rb` | Modify - add befoulment serialization |
| `app/blueprints/api/v1/weapon_series_blueprint.rb` | Modify - replace has_ax_skills with augment_type |
| `app/blueprints/api/v1/weapon_blueprint.rb` | Modify - update series.has_ax_skills reference |
| **Controllers** | |
| `app/controllers/api/v1/weapon_stat_modifiers_controller.rb` | Create |
| `app/controllers/api/v1/collection_weapons_controller.rb` | Modify - permit befoulment params |
| `app/controllers/api/v1/grid_weapons_controller.rb` | Modify - permit befoulment params |
| `app/controllers/api/v1/weapon_series_controller.rb` | Modify - permit augment_type instead of has_ax_skills |
| **Services & Seeds** | |
| `app/services/weapon_import_service.rb` | Modify - parse befoulments |
| `db/seeds/weapon_stat_modifiers.rb` | Create |
| **Config & Tests** | |
| `config/routes.rb` | Modify - add weapon_stat_modifiers route |
| `spec/factories/weapon_series.rb` | Modify - replace has_ax_skills with augment_type |
---
## API Breaking Changes
### 1. WeaponSeries: `has_ax_skills``augment_type`
**Before:**
```json
{ "has_ax_skills": true }
```
**After:**
```json
{ "augment_type": "ax" } // or "befoulment" or "none"
```
Frontend: `augment_type === 'ax'` instead of `has_ax_skills === true`.
### 2. Collection/GridWeapon: AX skill structure changed
**Before:**
```json
{
"ax_modifier1": 1589,
"ax_strength1": 3.0,
"ax_modifier2": 1719,
"ax_strength2": 2000
}
```
**After:**
```json
{
"ax": [
{
"modifier": { "id": 1, "slug": "ax_atk", "name_en": "ATK Up", "category": "ax", ... },
"strength": 3.0
},
{
"modifier": { "id": 5, "slug": "ax_ability_dmg", "name_en": "Ability DMG Up", "category": "ax", ... },
"strength": 2000
}
]
}
```
Frontend: Access via `weapon.ax[0].modifier.slug` instead of `weapon.ax_modifier1`.
---
## Implementation Considerations
### Migration Order
Migrations must be deployed in this order:
1. `create_weapon_stat_modifiers` - Create table + seed data
2. `add_ax_and_befoulment_fk_columns` - Add new FK columns (keep old integer columns)
3. `migrate_ax_modifiers_to_fk` - Data migration: lookup existing values → FK refs
4. `remove_old_ax_modifier_columns` - Remove old integer columns
5. `replace_has_ax_skills_with_augment_type` - WeaponSeries enum change
### GridWeapon Amoeba Config
Update nullify list for party cloning:
```ruby
amoeba do
nullify :ax_modifier1_id
nullify :ax_modifier2_id
nullify :ax_strength1
nullify :ax_strength2
nullify :befoulment_modifier_id
nullify :befoulment_strength
nullify :exorcism_level
end
```
### Unknown Skill ID Logging
When import encounters unknown `game_skill_id`, log with icon image for discovery:
```ruby
Rails.logger.warn(
"[WeaponImportService] Unknown augment skill_id=#{game_skill_id} " \
"icon=#{skill['augment_skill_icon_image']}"
)
```
### Database Indexes
Add index on `weapon_stat_modifiers.game_skill_id` for fast import lookups:
```ruby
add_index :weapon_stat_modifiers, :game_skill_id, unique: true
```
### Coordinated Release
Frontend and backend releases will be coordinated. No backwards compatibility layer needed.
---
## Next Steps
1. ✅ **Game JSON samples** - received for both Odiant and regular weapons
2. **Identify Odiant weapon series** in database (set `augment_type: :befoulment`)
3. **Discover remaining game_skill_ids** for other befoulment types (we know 2880 = DEF Down)
4. **Verify AX skill IDs** match between AX_MAPPING and game data
## Known Skill IDs
### AX Skills (Complete)
| skill_id | Stat | Name (EN) | Name (JP) | Suffix |
|----------|------|-----------|-----------|--------|
| 1588 | hp | HP | HP | % |
| 1589 | atk | ATK | 攻撃 | % |
| 1590 | def | DEF | 防御 | % |
| 1591 | ca_dmg | C.A. DMG | 奥義ダメ | % |
| 1592 | multiattack | Multiattack Rate | 連撃率 | % |
| 1593 | debuff_res | Debuff Resistance | 弱体耐性 | % |
| 1594 | ele_atk | Elemental ATK | 全属性攻撃力 | % |
| 1595 | healing | Healing | 回復性能 | % |
| 1596 | da | Double Attack Rate | DA確率 | % |
| 1597 | ta | Triple Attack Rate | TA確率 | % |
| 1599 | ca_cap | C.A. DMG Cap | 奥義上限 | % |
| 1600 | stamina | Stamina | 渾身 | - |
| 1601 | enmity | Enmity | 背水 | - |
| 1719 | skill_supp | Supplemental Skill DMG | アビ与ダメ上昇 | - |
| 1720 | ca_supp | Supplemental C.A. DMG | 奥義与ダメ上昇 | - |
| 1721 | ele_dmg_red | Elemental DMG Reduction | 属性ダメ軽減 | % |
| 1722 | na_cap | Normal ATK DMG Cap | 通常ダメ上限 | % |
| 1837 | exp | EXP Gain | EXP UP | % |
| 1838 | rupie | Rupie Gain | 獲得ルピ | % |
### Befoulments (Partial - more to discover)
| skill_id | Stat | Name (EN) | Name (JP) |
|----------|------|-----------|-----------|
| 2880 | def | DEF Down | 防御力DOWN |
| ? | atk | ATK Down | 攻撃力DOWN |
| ? | da_ta | DA/TA Down | 連撃率DOWN |
| ? | ca_dmg | CA DMG Down | 奥義ダメージDOWN |
| ? | dot | Damage Over Time | 毎ターンダメージ |
| ? | hp | Max HP Down | 最大HP減少 |
| ? | debuff_success | Debuff Success Down | 弱体成功率DOWN |
| ? | ability_dmg | Ability DMG Down | アビリティダメージDOWN |
(Befoulment skill_ids will be discovered as users import Odiant weapons)

View file

@ -8,12 +8,17 @@ FactoryBot.define do
awakening_level { 1 } awakening_level { 1 }
element { nil } # Only used for element-changeable weapons element { nil } # Only used for element-changeable weapons
# AX skills # AX skills (FK to weapon_stat_modifiers)
ax_modifier1 { nil } ax_modifier1 { nil }
ax_strength1 { nil } ax_strength1 { nil }
ax_modifier2 { nil } ax_modifier2 { nil }
ax_strength2 { nil } ax_strength2 { nil }
# Befoulment (FK to weapon_stat_modifiers)
befoulment_modifier { nil }
befoulment_strength { nil }
exorcism_level { 0 }
# Weapon keys # Weapon keys
weapon_key1 { nil } weapon_key1 { nil }
weapon_key2 { nil } weapon_key2 { nil }
@ -75,10 +80,24 @@ FactoryBot.define do
# Trait for AX weapon with skills # Trait for AX weapon with skills
trait :with_ax do trait :with_ax do
ax_modifier1 { 1 } # Attack modifier
ax_strength1 { 3.5 } ax_strength1 { 3.5 }
ax_modifier2 { 2 } # HP modifier
ax_strength2 { 10.0 } ax_strength2 { 10.0 }
after(:build) do |collection_weapon|
collection_weapon.ax_modifier1 = WeaponStatModifier.find_by(slug: 'ax_atk') ||
FactoryBot.create(:weapon_stat_modifier, :ax_atk)
collection_weapon.ax_modifier2 = WeaponStatModifier.find_by(slug: 'ax_hp') ||
FactoryBot.create(:weapon_stat_modifier, :ax_hp)
end
end
# Trait for Odiant weapon with befoulment
trait :with_befoulment do
befoulment_strength { 23.0 }
exorcism_level { 2 }
after(:build) do |collection_weapon|
collection_weapon.befoulment_modifier = WeaponStatModifier.find_by(slug: 'befoul_def_down') ||
FactoryBot.create(:weapon_stat_modifier, :befoul_def_down)
end
end end
# Trait for element-changed weapon (Revans weapons) # Trait for element-changed weapon (Revans weapons)

View file

@ -12,6 +12,39 @@ FactoryBot.define do
transcendence_step { 0 } transcendence_step { 0 }
mainhand { false } mainhand { false }
# AX skills (FK to weapon_stat_modifiers)
ax_modifier1 { nil }
ax_strength1 { nil }
ax_modifier2 { nil }
ax_strength2 { nil }
# Befoulment (FK to weapon_stat_modifiers)
befoulment_modifier { nil }
befoulment_strength { nil }
exorcism_level { 0 }
# Optional associations for weapon keys and awakening are left as nil by default. # Optional associations for weapon keys and awakening are left as nil by default.
# Trait for AX weapon with skills
trait :with_ax do
ax_strength1 { 3.5 }
ax_strength2 { 10.0 }
after(:build) do |grid_weapon|
grid_weapon.ax_modifier1 = WeaponStatModifier.find_by(slug: 'ax_atk') ||
FactoryBot.create(:weapon_stat_modifier, :ax_atk)
grid_weapon.ax_modifier2 = WeaponStatModifier.find_by(slug: 'ax_hp') ||
FactoryBot.create(:weapon_stat_modifier, :ax_hp)
end
end
# Trait for Odiant weapon with befoulment
trait :with_befoulment do
befoulment_strength { 23.0 }
exorcism_level { 2 }
after(:build) do |grid_weapon|
grid_weapon.befoulment_modifier = WeaponStatModifier.find_by(slug: 'befoul_def_down') ||
FactoryBot.create(:weapon_stat_modifier, :befoul_def_down)
end
end
end end
end end

View file

@ -9,7 +9,7 @@ FactoryBot.define do
element_changeable { false } element_changeable { false }
has_weapon_keys { false } has_weapon_keys { false }
has_awakening { false } has_awakening { false }
has_ax_skills { false } augment_type { :none }
trait :gacha do trait :gacha do
slug { 'gacha' } slug { 'gacha' }
@ -96,5 +96,21 @@ FactoryBot.define do
trait :with_weapon_keys do trait :with_weapon_keys do
has_weapon_keys { true } has_weapon_keys { true }
end end
trait :with_ax_skills do
augment_type { :ax }
end
trait :with_befoulments do
augment_type { :befoulment }
end
trait :odiant do
slug { 'odiant' }
name_en { 'Odiant' }
name_jp { '禁禍武器' }
order { 50 }
augment_type { :befoulment }
end
end end
end end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
FactoryBot.define do
factory :weapon_stat_modifier do
sequence(:slug) { |n| "ax-modifier-#{n}" }
sequence(:name_en) { |n| "AX Modifier #{n}" }
category { 'ax' }
polarity { 1 }
trait :ax_atk do
slug { 'ax_atk' }
name_en { 'ATK' }
category { 'ax' }
stat { 'atk' }
polarity { 1 }
suffix { '%' }
game_skill_id { 1589 }
end
trait :ax_hp do
slug { 'ax_hp' }
name_en { 'HP' }
category { 'ax' }
stat { 'hp' }
polarity { 1 }
suffix { '%' }
game_skill_id { 1588 }
end
trait :befoulment do
sequence(:slug) { |n| "befoul-modifier-#{n}" }
sequence(:name_en) { |n| "Befoulment #{n}" }
category { 'befoulment' }
polarity { -1 }
end
trait :befoul_def_down do
slug { 'befoul_def_down' }
name_en { 'DEF Down' }
category { 'befoulment' }
stat { 'def' }
polarity { -1 }
suffix { '%' }
game_skill_id { 2880 }
end
end
end

View file

@ -76,9 +76,14 @@ RSpec.describe CollectionWeapon, type: :model do
end end
describe 'AX skill validations' do describe 'AX skill validations' do
let(:ax_modifier) do
WeaponStatModifier.find_by(slug: 'ax_atk') ||
create(:weapon_stat_modifier, :ax_atk)
end
context 'when AX skill has only modifier' do context 'when AX skill has only modifier' do
it 'is invalid' do it 'is invalid' do
collection_weapon = build(:collection_weapon, ax_modifier1: 1, ax_strength1: nil) collection_weapon = build(:collection_weapon, ax_modifier1: ax_modifier, ax_strength1: nil)
expect(collection_weapon).not_to be_valid expect(collection_weapon).not_to be_valid
expect(collection_weapon.errors[:base]).to include('AX skill 1 must have both modifier and strength') expect(collection_weapon.errors[:base]).to include('AX skill 1 must have both modifier and strength')
end end
@ -94,7 +99,7 @@ RSpec.describe CollectionWeapon, type: :model do
context 'when AX skill has both modifier and strength' do context 'when AX skill has both modifier and strength' do
it 'is valid' do it 'is valid' do
collection_weapon = build(:collection_weapon, ax_modifier1: 1, ax_strength1: 3.5) collection_weapon = build(:collection_weapon, ax_modifier1: ax_modifier, ax_strength1: 3.5)
expect(collection_weapon).to be_valid expect(collection_weapon).to be_valid
end end
end end
@ -215,9 +220,11 @@ RSpec.describe CollectionWeapon, type: :model do
ax_weapon = create(:collection_weapon, :with_ax) ax_weapon = create(:collection_weapon, :with_ax)
aggregate_failures do aggregate_failures do
expect(ax_weapon.ax_modifier1).to eq(1) expect(ax_weapon.ax_modifier1).to be_a(WeaponStatModifier)
expect(ax_weapon.ax_modifier1.slug).to eq('ax_atk')
expect(ax_weapon.ax_strength1).to eq(3.5) expect(ax_weapon.ax_strength1).to eq(3.5)
expect(ax_weapon.ax_modifier2).to eq(2) expect(ax_weapon.ax_modifier2).to be_a(WeaponStatModifier)
expect(ax_weapon.ax_modifier2.slug).to eq('ax_hp')
expect(ax_weapon.ax_strength2).to eq(10.0) expect(ax_weapon.ax_strength2).to eq(10.0)
end end
end end

View file

@ -162,11 +162,16 @@ RSpec.describe 'Collection Weapons API', type: :request do
end end
it 'creates weapon with AX skills' do it 'creates weapon with AX skills' do
ax_atk = WeaponStatModifier.find_by(slug: 'ax_atk') ||
create(:weapon_stat_modifier, :ax_atk)
ax_hp = WeaponStatModifier.find_by(slug: 'ax_hp') ||
create(:weapon_stat_modifier, :ax_hp)
ax_attributes = valid_attributes.deep_merge( ax_attributes = valid_attributes.deep_merge(
collection_weapon: { collection_weapon: {
ax_modifier1: 1, ax_modifier1_id: ax_atk.id,
ax_strength1: 3.5, ax_strength1: 3.5,
ax_modifier2: 2, ax_modifier2_id: ax_hp.id,
ax_strength2: 2.0 ax_strength2: 2.0
} }
) )
@ -176,14 +181,17 @@ RSpec.describe 'Collection Weapons API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
json = JSON.parse(response.body) json = JSON.parse(response.body)
expect(json['ax']).to be_present expect(json['ax']).to be_present
expect(json['ax'].first['modifier']).to eq(1) expect(json['ax'].first['modifier']['slug']).to eq('ax_atk')
expect(json['ax'].first['strength']).to eq(3.5) expect(json['ax'].first['strength']).to eq(3.5)
end end
it 'returns error with incomplete AX skills' do it 'returns error with incomplete AX skills' do
ax_atk = WeaponStatModifier.find_by(slug: 'ax_atk') ||
create(:weapon_stat_modifier, :ax_atk)
invalid_ax = valid_attributes.deep_merge( invalid_ax = valid_attributes.deep_merge(
collection_weapon: { collection_weapon: {
ax_modifier1: 1 ax_modifier1_id: ax_atk.id
# Missing ax_strength1 # Missing ax_strength1
} }
) )

View file

@ -105,8 +105,8 @@ RSpec.describe 'GridWeapons API', type: :request do
weapon_key1_id: nil, weapon_key1_id: nil,
weapon_key2_id: nil, weapon_key2_id: nil,
weapon_key3_id: nil, weapon_key3_id: nil,
ax_modifier1: nil, ax_modifier1_id: nil,
ax_modifier2: nil, ax_modifier2_id: nil,
ax_strength1: nil, ax_strength1: nil,
ax_strength2: nil, ax_strength2: nil,
awakening_id: nil, awakening_id: nil,
@ -197,8 +197,8 @@ RSpec.describe 'GridWeapons API', type: :request do
weapon_key1_id: nil, weapon_key1_id: nil,
weapon_key2_id: nil, weapon_key2_id: nil,
weapon_key3_id: nil, weapon_key3_id: nil,
ax_modifier1: nil, ax_modifier1_id: nil,
ax_modifier2: nil, ax_modifier2_id: nil,
ax_strength1: nil, ax_strength1: nil,
ax_strength2: nil, ax_strength2: nil,
awakening_id: nil, awakening_id: nil,

View file

@ -40,7 +40,25 @@ RSpec.describe Processors::WeaponProcessor, type: :model do
describe '#process_weapon_ax' do describe '#process_weapon_ax' do
let(:grid_weapon) { build(:grid_weapon, party: party) } let(:grid_weapon) { build(:grid_weapon, party: party) }
it 'flattens nested augment_skill_info and assigns ax_modifier and ax_strength' do
let!(:ax_hp_modifier) do
WeaponStatModifier.find_by(slug: 'ax_hp') ||
create(:weapon_stat_modifier, :ax_hp)
end
let!(:ax_ca_dmg_modifier) do
WeaponStatModifier.find_by(slug: 'ax_ca_dmg') ||
create(:weapon_stat_modifier,
slug: 'ax_ca_dmg',
name_en: 'C.A. DMG',
category: 'ax',
stat: 'ca_dmg',
polarity: 1,
suffix: '%',
game_skill_id: 1591)
end
it 'flattens nested augment_skill_info and assigns ax_modifier_id and ax_strength' do
ax_skill_info = [ ax_skill_info = [
[ [
{ 'skill_id' => '1588', 'effect_value' => '3', 'show_value' => '3%' }, { 'skill_id' => '1588', 'effect_value' => '3', 'show_value' => '3%' },
@ -48,10 +66,10 @@ RSpec.describe Processors::WeaponProcessor, type: :model do
] ]
] ]
processor.send(:process_weapon_ax, grid_weapon, ax_skill_info) processor.send(:process_weapon_ax, grid_weapon, ax_skill_info)
expect(grid_weapon.ax_modifier1).to eq(2) # from 1588 → 2 expect(grid_weapon.ax_modifier1).to eq(ax_hp_modifier)
expect(grid_weapon.ax_strength1).to eq(3) expect(grid_weapon.ax_strength1).to eq(3.0)
expect(grid_weapon.ax_modifier2).to eq(3) # from 1591 → 3 expect(grid_weapon.ax_modifier2).to eq(ax_ca_dmg_modifier)
expect(grid_weapon.ax_strength2).to eq(5) expect(grid_weapon.ax_strength2).to eq(5.0)
end end
end end

View file

@ -57,6 +57,29 @@ RSpec.describe WeaponImportService, type: :service do
create(:awakening, :for_weapon, slug: 'weapon-heal', name_en: 'Healing') create(:awakening, :for_weapon, slug: 'weapon-heal', name_en: 'Healing')
end end
# Create weapon stat modifiers for AX skill tests
let!(:ax_atk_modifier) do
WeaponStatModifier.find_by(slug: 'ax_atk') ||
create(:weapon_stat_modifier, :ax_atk)
end
let!(:ax_hp_modifier) do
WeaponStatModifier.find_by(slug: 'ax_hp') ||
create(:weapon_stat_modifier, :ax_hp)
end
let!(:ax_ca_dmg_modifier) do
WeaponStatModifier.find_by(slug: 'ax_ca_dmg') ||
create(:weapon_stat_modifier,
slug: 'ax_ca_dmg',
name_en: 'C.A. DMG',
category: 'ax',
stat: 'ca_dmg',
polarity: 1,
suffix: '%',
game_skill_id: 1591)
end
before do before do
standard_weapon standard_weapon
transcendable_weapon transcendable_weapon
@ -279,12 +302,12 @@ RSpec.describe WeaponImportService, type: :service do
'augment_skill_info' => [ 'augment_skill_info' => [
[ [
{ {
'skill_id' => 1, 'skill_id' => 1589, # ATK modifier
'effect_value' => '7', 'effect_value' => '7',
'show_value' => '7%' 'show_value' => '7%'
}, },
{ {
'skill_id' => 2, 'skill_id' => 1588, # HP modifier
'effect_value' => '2_4', 'effect_value' => '2_4',
'show_value' => '4%' 'show_value' => '4%'
} }
@ -302,7 +325,7 @@ RSpec.describe WeaponImportService, type: :service do
result = service.import result = service.import
weapon = result.created.first weapon = result.created.first
expect(weapon.ax_modifier1).to eq(1) expect(weapon.ax_modifier1).to eq(ax_atk_modifier)
expect(weapon.ax_strength1).to eq(7.0) expect(weapon.ax_strength1).to eq(7.0)
end end
@ -311,7 +334,7 @@ RSpec.describe WeaponImportService, type: :service do
result = service.import result = service.import
weapon = result.created.first weapon = result.created.first
expect(weapon.ax_modifier2).to eq(2) expect(weapon.ax_modifier2).to eq(ax_hp_modifier)
expect(weapon.ax_strength2).to eq(4.0) expect(weapon.ax_strength2).to eq(4.0)
end end
end end
@ -329,7 +352,7 @@ RSpec.describe WeaponImportService, type: :service do
'augment_skill_info' => [ 'augment_skill_info' => [
[ [
{ {
'skill_id' => 3, 'skill_id' => 1591, # C.A. DMG modifier
'effect_value' => nil, 'effect_value' => nil,
'show_value' => '5.5%' 'show_value' => '5.5%'
} }
@ -347,7 +370,7 @@ RSpec.describe WeaponImportService, type: :service do
result = service.import result = service.import
weapon = result.created.first weapon = result.created.first
expect(weapon.ax_modifier1).to eq(3) expect(weapon.ax_modifier1).to eq(ax_ca_dmg_modifier)
expect(weapon.ax_strength1).to eq(5.5) expect(weapon.ax_strength1).to eq(5.5)
end end
end end