Add weapon stat modifiers for AX skills and befoulments (#202)

* add weapon_stat_modifiers table for ax skills and befoulments

* add fk columns for ax modifiers and befoulments, replace has_ax_skills with augment_type

* update models for weapon_stat_modifier fks and befoulments

* update blueprints for weapon_stat_modifier serialization

* update import service for weapon_stat_modifier fks and befoulments

* add weapon_stat_modifiers controller and update params for fks

* update tests and factories for weapon_stat_modifier fks

* fix remaining has_ax_skills and ax_modifier references

* add ax_modifier and befoulment_modifier to eager loading

* fix ax modifier column naming and migration approach

* add game_skill_ids for befoulment modifiers
This commit is contained in:
Justin Edmund 2025-12-31 22:20:00 -08:00 committed by GitHub
parent a6b7e26210
commit 1f80e4189f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1596 additions and 122 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

@ -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

@ -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

@ -14,7 +14,9 @@ module Api
@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]
@ -212,7 +214,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
@ -222,7 +225,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

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

@ -8,6 +8,10 @@ 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 has_many :grid_weapons, dependent: :nullify
before_destroy :orphan_grid_items before_destroy :orphan_grid_items
@ -18,9 +22,11 @@ class CollectionWeapon < ApplicationRecord
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
@ -29,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 }) }
@ -87,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

View file

@ -39,6 +39,10 @@ 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 # Orphan status scopes
scope :orphaned, -> { where(orphaned: true) } scope :orphaned, -> { where(orphaned: true) }
scope :not_orphaned, -> { where(orphaned: false) } scope :not_orphaned, -> { where(orphaned: false) }
@ -55,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
## ##
@ -84,10 +91,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
) )
@ -116,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

@ -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

@ -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

@ -35,6 +35,7 @@ class WeaponImportService
@skipped = [] @skipped = []
@errors = [] @errors = []
@awakening_cache = {} @awakening_cache = {}
@modifier_cache = {}
@processed_game_ids = [] @processed_game_ids = []
end end
@ -192,9 +193,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
@ -253,18 +263,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
@ -273,26 +283,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")

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

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,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