diff --git a/app/controllers/api/v1/collection_weapons_controller.rb b/app/controllers/api/v1/collection_weapons_controller.rb index ea8a75f..0869c02 100644 --- a/app/controllers/api/v1/collection_weapons_controller.rb +++ b/app/controllers/api/v1/collection_weapons_controller.rb @@ -177,7 +177,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 @@ -187,7 +188,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/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/config/routes.rb b/config/routes.rb index d417622..bffdfe4 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/seeds/weapon_stat_modifiers.rb b/db/seeds/weapon_stat_modifiers.rb new file mode 100644 index 0000000..ad4e0a1 --- /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 will be populated as we discover them + { 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: nil }, + { 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, game_skill_id: nil }, + { 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: nil }, + { 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: nil }, + { 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: nil }, + { 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: nil }, + { 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: nil } +] + +(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)