From 1f80e4189ff77e73e5c99ea5c705f939d3c494e8 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 31 Dec 2025 22:20:00 -0800 Subject: [PATCH] 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 --- .../api/v1/collection_weapon_blueprint.rb | 26 +- .../api/v1/grid_weapon_blueprint.rb | 28 +- app/blueprints/api/v1/weapon_blueprint.rb | 2 +- .../api/v1/weapon_series_blueprint.rb | 2 +- .../api/v1/weapon_stat_modifier_blueprint.rb | 10 + .../api/v1/collection_weapons_controller.rb | 10 +- .../api/v1/grid_weapons_controller.rb | 11 +- app/controllers/api/v1/parties_controller.rb | 7 +- .../api/v1/weapon_series_controller.rb | 2 +- .../v1/weapon_stat_modifiers_controller.rb | 21 + app/models/collection_weapon.rb | 37 +- app/models/grid_weapon.rb | 25 +- app/models/weapon_series.rb | 5 +- app/models/weapon_stat_modifier.rb | 31 + app/services/processors/weapon_processor.rb | 76 +- app/services/weapon_import_service.rb | 84 ++- config/routes.rb | 1 + ...251230000001_seed_weapon_stat_modifiers.rb | 55 ++ ...251230000002_migrate_ax_modifiers_to_fk.rb | 115 +++ ...230000003_add_befoulment_game_skill_ids.rb | 38 + db/data_schema.rb | 2 +- ...1230000001_create_weapon_stat_modifiers.rb | 24 + ...1230000002_add_weapon_stat_modifier_fks.rb | 36 + ...1230000003_finalize_ax_modifier_columns.rb | 11 + ...00004_add_augment_type_to_weapon_series.rb | 29 + db/schema.rb | 54 +- db/seed/test/weapon_series_test.csv | 24 +- db/seeds/weapon_stat_modifiers.rb | 56 ++ docs/planning/befoulments-feature-plan.md | 671 ++++++++++++++++++ spec/factories/collection_weapons.rb | 25 +- spec/factories/grid_weapons.rb | 33 + spec/factories/weapon_series.rb | 18 +- spec/factories/weapon_stat_modifiers.rb | 47 ++ spec/models/collection_weapon_spec.rb | 15 +- .../collection_weapons_controller_spec.rb | 16 +- spec/requests/grid_weapons_controller_spec.rb | 8 +- .../processors/weapon_processor_spec.rb | 28 +- spec/services/weapon_import_service_spec.rb | 35 +- 38 files changed, 1596 insertions(+), 122 deletions(-) create mode 100644 app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb create mode 100644 app/controllers/api/v1/weapon_stat_modifiers_controller.rb create mode 100644 app/models/weapon_stat_modifier.rb create mode 100644 db/data/20251230000001_seed_weapon_stat_modifiers.rb create mode 100644 db/data/20251230000002_migrate_ax_modifiers_to_fk.rb create mode 100644 db/data/20251230000003_add_befoulment_game_skill_ids.rb create mode 100644 db/migrate/20251230000001_create_weapon_stat_modifiers.rb create mode 100644 db/migrate/20251230000002_add_weapon_stat_modifier_fks.rb create mode 100644 db/migrate/20251230000003_finalize_ax_modifier_columns.rb create mode 100644 db/migrate/20251230000004_add_augment_type_to_weapon_series.rb create mode 100644 db/seeds/weapon_stat_modifiers.rb create mode 100644 docs/planning/befoulments-feature-plan.md create mode 100644 spec/factories/weapon_stat_modifiers.rb diff --git a/app/blueprints/api/v1/collection_weapon_blueprint.rb b/app/blueprints/api/v1/collection_weapon_blueprint.rb index 837907a..8892863 100644 --- a/app/blueprints/api/v1/collection_weapon_blueprint.rb +++ b/app/blueprints/api/v1/collection_weapon_blueprint.rb @@ -7,10 +7,28 @@ module Api :created_at, :updated_at field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj| - [ - { modifier: obj.ax_modifier1, strength: obj.ax_strength1 }, - { modifier: obj.ax_modifier2, strength: obj.ax_strength2 } - ].compact_blank + 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 + + 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 field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj| diff --git a/app/blueprints/api/v1/grid_weapon_blueprint.rb b/app/blueprints/api/v1/grid_weapon_blueprint.rb index 8e5540a..0e489a0 100644 --- a/app/blueprints/api/v1/grid_weapon_blueprint.rb +++ b/app/blueprints/api/v1/grid_weapon_blueprint.rb @@ -15,11 +15,29 @@ module Api end view :nested do - field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w| - [ - { modifier: w.ax_modifier1, strength: w.ax_strength1 }, - { modifier: w.ax_modifier2, strength: w.ax_strength2 } - ] + field :ax, if: ->(_field_name, w, _options) { w.ax_modifier1.present? } do |w| + skills = [] + if w.ax_modifier1.present? + 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 field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w| diff --git a/app/blueprints/api/v1/weapon_blueprint.rb b/app/blueprints/api/v1/weapon_blueprint.rb index 9d10027..4c12cf3 100644 --- a/app/blueprints/api/v1/weapon_blueprint.rb +++ b/app/blueprints/api/v1/weapon_blueprint.rb @@ -27,7 +27,7 @@ module Api }, has_weapon_keys: w.weapon_series.has_weapon_keys, 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, element_changeable: w.weapon_series.element_changeable } diff --git a/app/blueprints/api/v1/weapon_series_blueprint.rb b/app/blueprints/api/v1/weapon_series_blueprint.rb index ab18d5d..f73691b 100644 --- a/app/blueprints/api/v1/weapon_series_blueprint.rb +++ b/app/blueprints/api/v1/weapon_series_blueprint.rb @@ -11,7 +11,7 @@ module Api end fields :slug, :order, :extra, :element_changeable, :has_weapon_keys, - :has_awakening, :has_ax_skills + :has_awakening, :augment_type view :full do field :weapon_count do |ws| diff --git a/app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb b/app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb new file mode 100644 index 0000000..6b1e2e0 --- /dev/null +++ b/app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb @@ -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 diff --git a/app/controllers/api/v1/collection_weapons_controller.rb b/app/controllers/api/v1/collection_weapons_controller.rb index 74790a1..c22869a 100644 --- a/app/controllers/api/v1/collection_weapons_controller.rb +++ b/app/controllers/api/v1/collection_weapons_controller.rb @@ -14,7 +14,9 @@ module Api @collection_weapons = @target_user.collection_weapons .includes(:weapon, :awakening, :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) @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_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id, :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 ) end @@ -222,7 +225,8 @@ module Api :weapon_id, :uncap_level, :transcendence_step, :weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id, :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 ]) end diff --git a/app/controllers/api/v1/grid_weapons_controller.rb b/app/controllers/api/v1/grid_weapons_controller.rb index 7188dc0..984f698 100644 --- a/app/controllers/api/v1/grid_weapons_controller.rb +++ b/app/controllers/api/v1/grid_weapons_controller.rb @@ -291,13 +291,13 @@ module Api ## # 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] def normalize_ax_fields! - params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1 - - params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].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][:befoulment_modifier_id] = nil if weapon_params[:befoulment_modifier_id].to_i == -1 end ## @@ -515,7 +515,8 @@ module Api :id, :party_id, :weapon_id, :collection_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, + :ax_modifier1_id, :ax_modifier2_id, :ax_strength1, :ax_strength2, + :befoulment_modifier_id, :befoulment_strength, :exorcism_level, :awakening_id, :awakening_level ) end diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index be6b584..229e772 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -266,7 +266,10 @@ module Api awakening: {}, weapon_key1: {}, weapon_key2: {}, - weapon_key3: {} + weapon_key3: {}, + ax_modifier1: {}, + ax_modifier2: {}, + befoulment_modifier: {} } }, { summons: :summon }, :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], earring: %i[modifier strength] }], 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 diff --git a/app/controllers/api/v1/weapon_series_controller.rb b/app/controllers/api/v1/weapon_series_controller.rb index f29bdd6..b49f1f7 100644 --- a/app/controllers/api/v1/weapon_series_controller.rb +++ b/app/controllers/api/v1/weapon_series_controller.rb @@ -68,7 +68,7 @@ module Api params.require(:weapon_series).permit( :name_en, :name_jp, :slug, :order, :extra, :element_changeable, :has_weapon_keys, - :has_awakening, :has_ax_skills + :has_awakening, :augment_type ) end end diff --git a/app/controllers/api/v1/weapon_stat_modifiers_controller.rb b/app/controllers/api/v1/weapon_stat_modifiers_controller.rb new file mode 100644 index 0000000..235e629 --- /dev/null +++ b/app/controllers/api/v1/weapon_stat_modifiers_controller.rb @@ -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 diff --git a/app/models/collection_weapon.rb b/app/models/collection_weapon.rb index f369fad..7399ed0 100644 --- a/app/models/collection_weapon.rb +++ b/app/models/collection_weapon.rb @@ -8,6 +8,10 @@ class CollectionWeapon < ApplicationRecord belongs_to :weapon_key3, class_name: 'WeaponKey', optional: true belongs_to :weapon_key4, class_name: 'WeaponKey', optional: true + belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true + belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true + belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true + has_many :grid_weapons, dependent: :nullify before_destroy :orphan_grid_items @@ -18,9 +22,11 @@ class CollectionWeapon < ApplicationRecord validates :uncap_level, inclusion: { in: 0..5 } validates :transcendence_step, inclusion: { in: 0..10 } validates :awakening_level, inclusion: { in: 1..20 } + validates :exorcism_level, inclusion: { in: 0..5 }, allow_nil: true validate :validate_weapon_keys validate :validate_ax_skills + validate :validate_befoulment validate :validate_element_change validate :validate_awakening_compatibility validate :validate_awakening_level @@ -29,7 +35,7 @@ class CollectionWeapon < ApplicationRecord 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 :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_rarity, ->(rarity) { joins(:weapon).where(weapons: { rarity: rarity }) } scope :by_proficiency, ->(proficiency) { joins(:weapon).where(weapons: { proficiency: proficiency }) } @@ -87,16 +93,43 @@ class CollectionWeapon < ApplicationRecord end 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?) || (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 + # 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 def validate_element_change diff --git a/app/models/grid_weapon.rb b/app/models/grid_weapon.rb index b4f1164..b01b824 100644 --- a/app/models/grid_weapon.rb +++ b/app/models/grid_weapon.rb @@ -39,6 +39,10 @@ class GridWeapon < ApplicationRecord belongs_to :awakening, optional: true belongs_to :collection_weapon, optional: true + belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true + belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true + belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true + # Orphan status scopes scope :orphaned, -> { where(orphaned: true) } scope :not_orphaned, -> { where(orphaned: false) } @@ -55,10 +59,13 @@ class GridWeapon < ApplicationRecord ##### Amoeba configuration amoeba do - nullify :ax_modifier1 - nullify :ax_modifier2 + 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 ## @@ -84,10 +91,13 @@ class GridWeapon < ApplicationRecord weapon_key2_id: collection_weapon.weapon_key2_id, weapon_key3_id: collection_weapon.weapon_key3_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_modifier2: collection_weapon.ax_modifier2, + ax_modifier2_id: collection_weapon.ax_modifier2_id, 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_level: collection_weapon.awakening_level ) @@ -116,10 +126,13 @@ class GridWeapon < ApplicationRecord weapon_key2_id != collection_weapon.weapon_key2_id || weapon_key3_id != collection_weapon.weapon_key3_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_modifier2 != collection_weapon.ax_modifier2 || + ax_modifier2_id != collection_weapon.ax_modifier2_id || 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_level != collection_weapon.awakening_level end diff --git a/app/models/weapon_series.rb b/app/models/weapon_series.rb index 54cd116..bc8f9a2 100644 --- a/app/models/weapon_series.rb +++ b/app/models/weapon_series.rb @@ -5,6 +5,8 @@ class WeaponSeries < ApplicationRecord has_many :weapon_key_series, dependent: :destroy 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_jp, presence: true validates :slug, presence: true, uniqueness: true @@ -15,7 +17,8 @@ class WeaponSeries < ApplicationRecord scope :element_changeable, -> { where(element_changeable: true) } scope :with_weapon_keys, -> { where(has_weapon_keys: 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 DARK_OPUS = 'dark-opus' diff --git a/app/models/weapon_stat_modifier.rb b/app/models/weapon_stat_modifier.rb new file mode 100644 index 0000000..91fa115 --- /dev/null +++ b/app/models/weapon_stat_modifier.rb @@ -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 diff --git a/app/services/processors/weapon_processor.rb b/app/services/processors/weapon_processor.rb index 2eaf5c0..987c0a5 100644 --- a/app/services/processors/weapon_processor.rb +++ b/app/services/processors/weapon_processor.rb @@ -41,26 +41,6 @@ module Processors class WeaponProcessor < BaseProcessor TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze - # Mapping from in‑game 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. # 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. # # The deck stores AX skills in an array of arrays under "augment_skill_info". - # This method flattens the data and assigns each skill’s 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 ax_skill_info [Array] the raw AX skill info. @@ -340,14 +321,59 @@ module Processors # Flatten the nested array structure. ax_skills = ax_skill_info.flatten ax_skills.each_with_index do |ax, idx| - ax_id = ax['skill_id'].to_s - ax_mod = AX_MAPPING[ax_id] || ax_id.to_i - strength = ax['effect_value'].to_s.gsub(/[+%]/, '').to_i - grid_weapon["ax_modifier#{idx + 1}"] = ax_mod + break if idx >= 2 # Only 2 AX skill slots + + game_skill_id = ax['skill_id'].to_i + 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 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 in‑game awakening data (stored under "arousal") to our Awakening record. # diff --git a/app/services/weapon_import_service.rb b/app/services/weapon_import_service.rb index d0c8a98..1522531 100644 --- a/app/services/weapon_import_service.rb +++ b/app/services/weapon_import_service.rb @@ -35,6 +35,7 @@ class WeaponImportService @skipped = [] @errors = [] @awakening_cache = {} + @modifier_cache = {} @processed_game_ids = [] end @@ -192,9 +193,18 @@ class WeaponImportService awakening_attrs = parse_awakening(param['arousal']) attrs.merge!(awakening_attrs) if awakening_attrs - # Parse AX skills if present - ax_attrs = parse_ax_skills(param['augment_skill_info']) - attrs.merge!(ax_attrs) if ax_attrs + # Check if this is an Odiant (befoulment) weapon + odiant = param['odiant'] + if odiant && odiant['is_odiant_weapon'] == true + # Parse befoulment from augment_skill_info + befoulment_attrs = parse_befoulment(param['augment_skill_info']) + attrs.merge!(befoulment_attrs) if befoulment_attrs + attrs[:exorcism_level] = odiant['exorcision_level'].to_i.clamp(0, 5) + else + # Regular weapon - parse AX skills + ax_attrs = parse_ax_skills(param['augment_skill_info']) + attrs.merge!(ax_attrs) if ax_attrs + end attrs end @@ -253,18 +263,18 @@ class WeaponImportService # First AX skill if skills[0].is_a?(Hash) - ax1 = parse_single_ax_skill(skills[0]) + ax1 = parse_single_augment_skill(skills[0]) if ax1 - attrs[:ax_modifier1] = ax1[:modifier] + 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]) + ax2 = parse_single_augment_skill(skills[1]) if ax2 - attrs[:ax_modifier2] = ax2[:modifier] + attrs[:ax_modifier2_id] = ax2[:modifier_id] attrs[:ax_strength2] = ax2[:strength] end end @@ -273,26 +283,60 @@ class WeaponImportService 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 - # @return [Hash, nil] { modifier:, strength: } or nil - def parse_single_ax_skill(skill) - return nil unless skill['skill_id'].present? + # @param augment_skill_info [Array] The game's augment skill data + # @return [Hash, nil] Befoulment attributes or nil if no befoulment + def parse_befoulment(augment_skill_info) + return nil if augment_skill_info.blank? || !augment_skill_info.is_a?(Array) - # The skill_id maps to our AX modifier - modifier = skill['skill_id'].to_i + skills = augment_skill_info.first + return nil if skills.blank? || !skills.is_a?(Array) - # Parse strength from effect_value (may be "3" or "1_3" format) - # or from show_value (may be "3%" format) - strength = parse_ax_strength(skill['effect_value'], skill['show_value']) + skill = skills.first + return nil unless skill.is_a?(Hash) - 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 - 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 if effect_value.present? # Handle "1_3" format (seems to be "tier_value") diff --git a/config/routes.rb b/config/routes.rb index 8676b00..fc20e62 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -128,6 +128,7 @@ Rails.application.routes.draw do get 'for_slot/:slot', action: :for_slot, as: :for_slot end end + resources :weapon_stat_modifiers, only: %i[index show] # Grid artifacts resources :grid_artifacts, only: %i[create update destroy] do diff --git a/db/data/20251230000001_seed_weapon_stat_modifiers.rb b/db/data/20251230000001_seed_weapon_stat_modifiers.rb new file mode 100644 index 0000000..b7887be --- /dev/null +++ b/db/data/20251230000001_seed_weapon_stat_modifiers.rb @@ -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 diff --git a/db/data/20251230000002_migrate_ax_modifiers_to_fk.rb b/db/data/20251230000002_migrate_ax_modifiers_to_fk.rb new file mode 100644 index 0000000..96782a4 --- /dev/null +++ b/db/data/20251230000002_migrate_ax_modifiers_to_fk.rb @@ -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 diff --git a/db/data/20251230000003_add_befoulment_game_skill_ids.rb b/db/data/20251230000003_add_befoulment_game_skill_ids.rb new file mode 100644 index 0000000..b8469c0 --- /dev/null +++ b/db/data/20251230000003_add_befoulment_game_skill_ids.rb @@ -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 diff --git a/db/data_schema.rb b/db/data_schema.rb index 07672e6..bb71a21 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 20251214193836) +DataMigrate::Data.define(version: 20251230000002) diff --git a/db/migrate/20251230000001_create_weapon_stat_modifiers.rb b/db/migrate/20251230000001_create_weapon_stat_modifiers.rb new file mode 100644 index 0000000..750a733 --- /dev/null +++ b/db/migrate/20251230000001_create_weapon_stat_modifiers.rb @@ -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 diff --git a/db/migrate/20251230000002_add_weapon_stat_modifier_fks.rb b/db/migrate/20251230000002_add_weapon_stat_modifier_fks.rb new file mode 100644 index 0000000..4f15f92 --- /dev/null +++ b/db/migrate/20251230000002_add_weapon_stat_modifier_fks.rb @@ -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 diff --git a/db/migrate/20251230000003_finalize_ax_modifier_columns.rb b/db/migrate/20251230000003_finalize_ax_modifier_columns.rb new file mode 100644 index 0000000..281a63d --- /dev/null +++ b/db/migrate/20251230000003_finalize_ax_modifier_columns.rb @@ -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 diff --git a/db/migrate/20251230000004_add_augment_type_to_weapon_series.rb b/db/migrate/20251230000004_add_augment_type_to_weapon_series.rb new file mode 100644 index 0000000..2714b5b --- /dev/null +++ b/db/migrate/20251230000004_add_augment_type_to_weapon_series.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b2c3b2d..9442424 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "btree_gin" 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 "awakening_id" t.integer "awakening_level", default: 1, null: false - t.integer "ax_modifier1" t.float "ax_strength1" - t.integer "ax_modifier2" t.float "ax_strength2" t.integer "element" t.datetime "created_at", null: false t.datetime "updated_at", null: false 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 ["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", "weapon_id"], name: "index_collection_weapons_on_user_id_and_weapon_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.integer "reroll_slot" 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 ["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 ["orphaned"], name: "index_grid_artifacts_on_orphaned" end 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.boolean "quick_summon", default: false 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 ["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"], name: "index_grid_summons_on_party_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 "updated_at", null: false t.uuid "weapon_key3_id" - t.integer "ax_modifier1" t.float "ax_strength1" - t.integer "ax_modifier2" t.float "ax_strength2" t.integer "element" 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.string "weapon_key4_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 ["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 ["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"], name: "index_grid_weapons_on_party_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 "has_weapon_keys", 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 ["slug"], name: "index_weapon_series_on_slug", unique: true 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" 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| t.string "name_en" 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_key3_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 "crew_gw_participations", "crews" 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", "parties" 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 "gw_crew_scores", "crew_gw_participations" add_foreign_key "gw_individual_scores", "crew_gw_participations" diff --git a/db/seed/test/weapon_series_test.csv b/db/seed/test/weapon_series_test.csv index 60f0ea4..cf882f4 100644 --- a/db/seed/test/weapon_series_test.csv +++ b/db/seed/test/weapon_series_test.csv @@ -1,12 +1,12 @@ -id,slug,name_en,name_jp,order,extra,element_changeable,has_weapon_keys,has_awakening,has_ax_skills -00000000-0001-0000-0000-000000000001,gacha,Gacha Weapons,ガチャ武器,0,false,false,false,false,false -00000000-0001-0000-0000-000000000002,grand,Grand Weapons,リミテッドシリーズ,2,false,false,true,false,false -00000000-0001-0000-0000-000000000003,dark-opus,Dark Opus Weapons,終末の神器,3,false,false,true,true,false -00000000-0001-0000-0000-000000000004,revenant,Revenant Weapons,天星器,21,false,true,false,false,false -00000000-0001-0000-0000-000000000005,xeno,Xeno Weapons,六道武器,23,true,false,false,false,false -00000000-0001-0000-0000-000000000006,ultima,Ultima Weapons,オメガウェポン,9,false,true,true,false,false -00000000-0001-0000-0000-000000000007,superlative,Superlative Weapons,スペリオシリーズ,27,true,true,true,false,false -00000000-0001-0000-0000-000000000008,draconic,Draconic Weapons,ドラコニックウェポン・オリジン,5,false,false,true,true,false -00000000-0001-0000-0000-000000000009,draconic-providence,Draconic Weapons Providence,ドラコニックウェポン,6,false,false,true,true,false -00000000-0001-0000-0000-000000000010,primal,Primal Weapons,プライマルシリーズ,32,false,false,false,false,false -00000000-0001-0000-0000-000000000011,event,Event Weapons,イベント武器,99,false,false,false,false,false +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,none +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,none +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,none +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,none +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,none +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,none diff --git a/db/seeds/weapon_stat_modifiers.rb b/db/seeds/weapon_stat_modifiers.rb new file mode 100644 index 0000000..5667da6 --- /dev/null +++ b/db/seeds/weapon_stat_modifiers.rb @@ -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" diff --git a/docs/planning/befoulments-feature-plan.md b/docs/planning/befoulments-feature-plan.md new file mode 100644 index 0000000..4cd3814 --- /dev/null +++ b/docs/planning/befoulments-feature-plan.md @@ -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) diff --git a/spec/factories/collection_weapons.rb b/spec/factories/collection_weapons.rb index 6792b76..ec22549 100644 --- a/spec/factories/collection_weapons.rb +++ b/spec/factories/collection_weapons.rb @@ -8,12 +8,17 @@ FactoryBot.define do awakening_level { 1 } element { nil } # Only used for element-changeable weapons - # AX skills + # 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 } + # Weapon keys weapon_key1 { nil } weapon_key2 { nil } @@ -75,10 +80,24 @@ FactoryBot.define do # Trait for AX weapon with skills trait :with_ax do - ax_modifier1 { 1 } # Attack modifier ax_strength1 { 3.5 } - ax_modifier2 { 2 } # HP modifier 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 # Trait for element-changed weapon (Revans weapons) diff --git a/spec/factories/grid_weapons.rb b/spec/factories/grid_weapons.rb index 765b70c..0dc74b5 100644 --- a/spec/factories/grid_weapons.rb +++ b/spec/factories/grid_weapons.rb @@ -12,6 +12,39 @@ FactoryBot.define do transcendence_step { 0 } 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. + + # 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 diff --git a/spec/factories/weapon_series.rb b/spec/factories/weapon_series.rb index 21436bf..28b61d0 100644 --- a/spec/factories/weapon_series.rb +++ b/spec/factories/weapon_series.rb @@ -9,7 +9,7 @@ FactoryBot.define do element_changeable { false } has_weapon_keys { false } has_awakening { false } - has_ax_skills { false } + augment_type { :none } trait :gacha do slug { 'gacha' } @@ -96,5 +96,21 @@ FactoryBot.define do trait :with_weapon_keys do has_weapon_keys { true } 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 diff --git a/spec/factories/weapon_stat_modifiers.rb b/spec/factories/weapon_stat_modifiers.rb new file mode 100644 index 0000000..67a7d89 --- /dev/null +++ b/spec/factories/weapon_stat_modifiers.rb @@ -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 diff --git a/spec/models/collection_weapon_spec.rb b/spec/models/collection_weapon_spec.rb index 24a5f8b..232d949 100644 --- a/spec/models/collection_weapon_spec.rb +++ b/spec/models/collection_weapon_spec.rb @@ -76,9 +76,14 @@ RSpec.describe CollectionWeapon, type: :model do end 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 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.errors[:base]).to include('AX skill 1 must have both modifier and strength') end @@ -94,7 +99,7 @@ RSpec.describe CollectionWeapon, type: :model do context 'when AX skill has both modifier and strength' 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 end end @@ -215,9 +220,11 @@ RSpec.describe CollectionWeapon, type: :model do ax_weapon = create(:collection_weapon, :with_ax) 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_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) end end diff --git a/spec/requests/collection_weapons_controller_spec.rb b/spec/requests/collection_weapons_controller_spec.rb index 89351e8..310cb80 100644 --- a/spec/requests/collection_weapons_controller_spec.rb +++ b/spec/requests/collection_weapons_controller_spec.rb @@ -162,11 +162,16 @@ RSpec.describe 'Collection Weapons API', type: :request do end 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( collection_weapon: { - ax_modifier1: 1, + ax_modifier1_id: ax_atk.id, ax_strength1: 3.5, - ax_modifier2: 2, + ax_modifier2_id: ax_hp.id, ax_strength2: 2.0 } ) @@ -176,14 +181,17 @@ RSpec.describe 'Collection Weapons API', type: :request do expect(response).to have_http_status(:created) json = JSON.parse(response.body) 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) end 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( collection_weapon: { - ax_modifier1: 1 + ax_modifier1_id: ax_atk.id # Missing ax_strength1 } ) diff --git a/spec/requests/grid_weapons_controller_spec.rb b/spec/requests/grid_weapons_controller_spec.rb index c162724..f1963f8 100644 --- a/spec/requests/grid_weapons_controller_spec.rb +++ b/spec/requests/grid_weapons_controller_spec.rb @@ -105,8 +105,8 @@ RSpec.describe 'GridWeapons API', type: :request do weapon_key1_id: nil, weapon_key2_id: nil, weapon_key3_id: nil, - ax_modifier1: nil, - ax_modifier2: nil, + ax_modifier1_id: nil, + ax_modifier2_id: nil, ax_strength1: nil, ax_strength2: nil, awakening_id: nil, @@ -197,8 +197,8 @@ RSpec.describe 'GridWeapons API', type: :request do weapon_key1_id: nil, weapon_key2_id: nil, weapon_key3_id: nil, - ax_modifier1: nil, - ax_modifier2: nil, + ax_modifier1_id: nil, + ax_modifier2_id: nil, ax_strength1: nil, ax_strength2: nil, awakening_id: nil, diff --git a/spec/services/processors/weapon_processor_spec.rb b/spec/services/processors/weapon_processor_spec.rb index 647c993..7798ee7 100644 --- a/spec/services/processors/weapon_processor_spec.rb +++ b/spec/services/processors/weapon_processor_spec.rb @@ -40,7 +40,25 @@ RSpec.describe Processors::WeaponProcessor, type: :model do describe '#process_weapon_ax' do 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 = [ [ { '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) - expect(grid_weapon.ax_modifier1).to eq(2) # from 1588 → 2 - expect(grid_weapon.ax_strength1).to eq(3) - expect(grid_weapon.ax_modifier2).to eq(3) # from 1591 → 3 - expect(grid_weapon.ax_strength2).to eq(5) + expect(grid_weapon.ax_modifier1).to eq(ax_hp_modifier) + expect(grid_weapon.ax_strength1).to eq(3.0) + expect(grid_weapon.ax_modifier2).to eq(ax_ca_dmg_modifier) + expect(grid_weapon.ax_strength2).to eq(5.0) end end diff --git a/spec/services/weapon_import_service_spec.rb b/spec/services/weapon_import_service_spec.rb index 770ff7d..1ca7624 100644 --- a/spec/services/weapon_import_service_spec.rb +++ b/spec/services/weapon_import_service_spec.rb @@ -57,6 +57,29 @@ RSpec.describe WeaponImportService, type: :service do create(:awakening, :for_weapon, slug: 'weapon-heal', name_en: 'Healing') 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 standard_weapon transcendable_weapon @@ -279,12 +302,12 @@ RSpec.describe WeaponImportService, type: :service do 'augment_skill_info' => [ [ { - 'skill_id' => 1, + 'skill_id' => 1589, # ATK modifier 'effect_value' => '7', 'show_value' => '7%' }, { - 'skill_id' => 2, + 'skill_id' => 1588, # HP modifier 'effect_value' => '2_4', 'show_value' => '4%' } @@ -302,7 +325,7 @@ RSpec.describe WeaponImportService, type: :service do result = service.import 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) end @@ -311,7 +334,7 @@ RSpec.describe WeaponImportService, type: :service do result = service.import 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) end end @@ -329,7 +352,7 @@ RSpec.describe WeaponImportService, type: :service do 'augment_skill_info' => [ [ { - 'skill_id' => 3, + 'skill_id' => 1591, # C.A. DMG modifier 'effect_value' => nil, 'show_value' => '5.5%' } @@ -347,7 +370,7 @@ RSpec.describe WeaponImportService, type: :service do result = service.import 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) end end