add artifact import from game data
- Add ArtifactImportService to parse game JSON and create collection artifacts - Maps game skill_id to our (group, modifier) format using verified mapping - Handles skill quality -> strength lookup via ArtifactSkill.base_values - Supports duplicate detection via game_id, with optional update_existing flag - Quirk artifacts get proficiency from game data; skills stored as empty - Add POST /collection/artifacts/import endpoint - Add game_id column to collection_artifacts, collection_weapons, collection_summons for tracking game inventory instance IDs
This commit is contained in:
parent
1cbfa90428
commit
6cf85a5b3e
5 changed files with 337 additions and 2 deletions
|
|
@ -9,7 +9,7 @@ module Api
|
|||
before_action :set_collection_artifact_for_read, only: %i[show]
|
||||
|
||||
# Write actions: require auth, use current_user
|
||||
before_action :restrict_access, only: %i[create update destroy batch]
|
||||
before_action :restrict_access, only: %i[create update destroy batch import]
|
||||
before_action :set_collection_artifact_for_write, only: %i[update destroy]
|
||||
|
||||
def index
|
||||
|
|
@ -95,6 +95,37 @@ module Api
|
|||
), status: status
|
||||
end
|
||||
|
||||
# POST /collection/artifacts/import
|
||||
# Imports artifacts from game JSON data
|
||||
#
|
||||
# @param data [Hash] Game data containing artifact list
|
||||
# @param update_existing [Boolean] Whether to update existing artifacts (default: false)
|
||||
def import
|
||||
game_data = import_params[:data]
|
||||
|
||||
unless game_data.present?
|
||||
return render json: { error: 'No data provided' }, status: :bad_request
|
||||
end
|
||||
|
||||
service = ArtifactImportService.new(
|
||||
current_user,
|
||||
game_data,
|
||||
update_existing: import_params[:update_existing] == true
|
||||
)
|
||||
|
||||
result = service.import
|
||||
|
||||
status = result.success? ? :created : :multi_status
|
||||
|
||||
render json: {
|
||||
success: result.success?,
|
||||
created: result.created.size,
|
||||
updated: result.updated.size,
|
||||
skipped: result.skipped.size,
|
||||
errors: result.errors
|
||||
}, status: status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_target_user
|
||||
|
|
@ -142,6 +173,10 @@ module Api
|
|||
{ skill4: %i[modifier strength level] }
|
||||
])
|
||||
end
|
||||
|
||||
def import_params
|
||||
params.permit(:update_existing, data: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
275
app/services/artifact_import_service.rb
Normal file
275
app/services/artifact_import_service.rb
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Service for importing artifacts from game JSON data.
|
||||
# Parses the game's skill_id format and converts to our (group, modifier) format.
|
||||
#
|
||||
# @example Import artifacts for a user
|
||||
# service = ArtifactImportService.new(user, game_data)
|
||||
# result = service.import
|
||||
# if result.success?
|
||||
# puts "Imported #{result.created_count} artifacts"
|
||||
# end
|
||||
#
|
||||
class ArtifactImportService
|
||||
Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true)
|
||||
|
||||
# Mapping from game skill base ID (skill_id / 10) to [group, modifier]
|
||||
# Built from analyzing game data samples and verified against artifact_skills.json
|
||||
GAME_SKILL_MAPPING = {
|
||||
# === GROUP I (Lines 1-2) ===
|
||||
1001 => [1, 1], # ATK
|
||||
2001 => [1, 2], # HP
|
||||
3001 => [1, 6], # Critical Hit Rate
|
||||
3002 => [1, 3], # C.A. DMG
|
||||
3003 => [1, 4], # Skill DMG
|
||||
3004 => [1, 13], # Debuff Success Rate
|
||||
3005 => [1, 7], # Double Attack Rate
|
||||
3006 => [1, 8], # Triple Attack Rate
|
||||
3007 => [1, 9], # DEF
|
||||
3008 => [1, 14], # Debuff Resistance
|
||||
3009 => [1, 11], # Dodge Rate
|
||||
3010 => [1, 12], # Healing
|
||||
3011 => [1, 5], # Elemental ATK
|
||||
3012 => [1, 10], # Superior Element Reduction
|
||||
|
||||
# === GROUP II (Line 3) ===
|
||||
3013 => [2, 1], # N.A. DMG Cap
|
||||
3014 => [2, 2], # Skill DMG Cap
|
||||
3015 => [2, 3], # C.A. DMG Cap
|
||||
3016 => [2, 9], # Supplemental N.A. DMG
|
||||
3017 => [2, 10], # Supplemental Skill DMG
|
||||
3018 => [2, 11], # Supplemental C.A. DMG
|
||||
3019 => [2, 4], # Special C.A. DMG Cap
|
||||
3020 => [2, 6], # N.A. DMG cap boost tradeoff
|
||||
3021 => [2, 18], # Turn-Based DMG Reduction
|
||||
3022 => [2, 17], # Regeneration
|
||||
3023 => [2, 14], # Amplify DMG at 100% HP
|
||||
3024 => [2, 13], # Boost TA at 50%+ HP
|
||||
3025 => [2, 16], # DMG reduction when at or below 50% HP
|
||||
3026 => [2, 5], # Boost DMG cap for critical hits
|
||||
3027 => [2, 15], # Max HP boost for a 70% hit to DEF
|
||||
3028 => [2, 12], # Chain DMG Amplify
|
||||
3029 => [2, 7], # Skill DMG cap boost tradeoff
|
||||
3030 => [2, 8], # C.A. DMG cap boost tradeoff
|
||||
4000 => [2, 19], # Chance to remove 1 debuff before attacking
|
||||
4001 => [2, 20], # Chance to cancel incoming dispels
|
||||
|
||||
# === GROUP III (Line 4) ===
|
||||
3031 => [3, 28], # Boost item drop rate
|
||||
3032 => [3, 27], # Boost EXP earned
|
||||
5001 => [3, 2], # At battle start: Gain x random buff(s)
|
||||
5002 => [3, 8], # Upon using a debuff skill: Amplify foe's DMG taken
|
||||
5003 => [3, 9], # Upon using a healing skill: Ally bonus
|
||||
5004 => [3, 12], # Cut linked skill cooldowns
|
||||
5005 => [3, 10], # Gain 1% DMG Cap Up (Stackable)
|
||||
5006 => [3, 11], # After using a skill with a cooldown of 10+ turns
|
||||
5007 => [3, 22], # Gain Supplemental Skill DMG (Stackable)
|
||||
5008 => [3, 13], # Gain Supplemental DMG based on charge bar spent
|
||||
5009 => [3, 20], # Gain Flurry (3-hit)
|
||||
5010 => [3, 21], # Plain DMG based on HP lost
|
||||
5011 => [3, 23], # Upon single attacks: Gain random buff
|
||||
5012 => [3, 25], # When foe has 3 or fewer debuffs: Armored
|
||||
5013 => [3, 6], # When foe HP at 50% or lower: Restore HP
|
||||
5014 => [3, 26], # When a sub ally: random debuff to foes
|
||||
5015 => [3, 19], # Gain 20% Bonus DMG after being targeted
|
||||
5016 => [3, 14], # At end of turn if didn't attack: Gain buff
|
||||
5017 => [3, 24], # Upon using potion: Boost FC bar
|
||||
5018 => [3, 3], # Start with 20% HP consumed / Cap Up
|
||||
5019 => [3, 4], # When knocked out: All allies gain buffs
|
||||
5020 => [3, 5], # When switching to main ally: Amplify DMG
|
||||
5021 => [3, 1], # At battle start: Gain DMG Mitigation
|
||||
5022 => [3, 18], # Chance to gain Flurry (6-hit)
|
||||
5023 => [3, 17], # At battle start and every 5 turns: Shield
|
||||
5024 => [3, 16], # Chance of turn progressing by 5
|
||||
5025 => [3, 7], # Upon first-slot skill: Cut CD
|
||||
5026 => [3, 15], # Chance to remove all buffs from foe
|
||||
5029 => [3, 29] # May find earrings
|
||||
}.freeze
|
||||
|
||||
# Game element values to our element enum values
|
||||
# Game: 1=Fire, 2=Water, 3=Earth, 4=Wind, 5=Light, 6=Dark
|
||||
# Ours: wind=1, fire=2, water=3, earth=4, dark=5, light=6
|
||||
ELEMENT_MAPPING = {
|
||||
'1' => 2, # Fire
|
||||
'2' => 3, # Water
|
||||
'3' => 4, # Earth
|
||||
'4' => 1, # Wind
|
||||
'5' => 6, # Light
|
||||
'6' => 5 # Dark
|
||||
}.freeze
|
||||
|
||||
def initialize(user, game_data, options = {})
|
||||
@user = user
|
||||
@game_data = game_data
|
||||
@update_existing = options[:update_existing] || false
|
||||
@created = []
|
||||
@updated = []
|
||||
@skipped = []
|
||||
@errors = []
|
||||
end
|
||||
|
||||
##
|
||||
# Imports artifacts from game data.
|
||||
#
|
||||
# @return [Result] Import result with counts and errors
|
||||
def import
|
||||
items = extract_items
|
||||
return Result.new(success?: false, errors: ['No artifact items found in data']) if items.empty?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
items.each_with_index do |item, index|
|
||||
import_item(item, index)
|
||||
rescue StandardError => e
|
||||
@errors << { index: index, game_id: item['id'], error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
Result.new(
|
||||
success?: @errors.empty?,
|
||||
created: @created,
|
||||
updated: @updated,
|
||||
skipped: @skipped,
|
||||
errors: @errors
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_items
|
||||
return @game_data['list'] if @game_data['list'].is_a?(Array)
|
||||
return @game_data if @game_data.is_a?(Array)
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
def import_item(item, _index)
|
||||
artifact = find_artifact(item['artifact_id'])
|
||||
unless artifact
|
||||
@errors << { game_id: item['id'], artifact_id: item['artifact_id'], error: 'Artifact not found' }
|
||||
return
|
||||
end
|
||||
|
||||
# Check for existing collection artifact with same game ID
|
||||
existing = @user.collection_artifacts.find_by(game_id: item['id'].to_s)
|
||||
|
||||
if existing
|
||||
if @update_existing
|
||||
update_existing_artifact(existing, item, artifact)
|
||||
else
|
||||
@skipped << { game_id: item['id'], reason: 'Already exists' }
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
create_collection_artifact(item, artifact)
|
||||
end
|
||||
|
||||
def find_artifact(artifact_id)
|
||||
Artifact.find_by(granblue_id: artifact_id.to_s)
|
||||
end
|
||||
|
||||
def create_collection_artifact(item, artifact)
|
||||
attrs = build_collection_artifact_attrs(item, artifact)
|
||||
|
||||
collection_artifact = @user.collection_artifacts.build(attrs)
|
||||
|
||||
if collection_artifact.save
|
||||
@created << collection_artifact
|
||||
else
|
||||
@errors << {
|
||||
game_id: item['id'],
|
||||
artifact_id: item['artifact_id'],
|
||||
error: collection_artifact.errors.full_messages.join(', ')
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def update_existing_artifact(existing, item, artifact)
|
||||
attrs = build_collection_artifact_attrs(item, artifact)
|
||||
|
||||
if existing.update(attrs)
|
||||
@updated << existing
|
||||
else
|
||||
@errors << {
|
||||
game_id: item['id'],
|
||||
artifact_id: item['artifact_id'],
|
||||
error: existing.errors.full_messages.join(', ')
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_collection_artifact_attrs(item, artifact)
|
||||
{
|
||||
artifact: artifact,
|
||||
game_id: item['id'].to_s,
|
||||
element: map_element(item['attribute']),
|
||||
proficiency: artifact.quirk? ? map_proficiency(item['kind']) : nil,
|
||||
level: item['level'].to_i,
|
||||
skill1: parse_skill(item['skill1_info']),
|
||||
skill2: parse_skill(item['skill2_info']),
|
||||
skill3: parse_skill(item['skill3_info']),
|
||||
skill4: parse_skill(item['skill4_info'])
|
||||
}
|
||||
end
|
||||
|
||||
def map_element(game_element)
|
||||
ELEMENT_MAPPING[game_element.to_s]
|
||||
end
|
||||
|
||||
def map_proficiency(kind)
|
||||
# Game 'kind' field maps directly to proficiency enum (1-10)
|
||||
kind.to_i
|
||||
end
|
||||
|
||||
def parse_skill(skill_info)
|
||||
return {} if skill_info.blank?
|
||||
|
||||
skill_id = skill_info['skill_id']
|
||||
quality = skill_info['skill_quality'] || skill_info['level']
|
||||
level = skill_info['level'] || 1
|
||||
|
||||
group, modifier = decode_skill_id(skill_id)
|
||||
return {} unless group && modifier
|
||||
|
||||
# Get the strength value from ArtifactSkill
|
||||
strength = calculate_strength(group, modifier, quality.to_i)
|
||||
|
||||
{
|
||||
'modifier' => modifier,
|
||||
'strength' => strength,
|
||||
'level' => level.to_i
|
||||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Decodes a game skill_id to [group, modifier].
|
||||
# skill_id format: {base_id}{quality} where last digit is quality (1-5)
|
||||
#
|
||||
# @param skill_id [Integer] The game's skill ID
|
||||
# @return [Array<Integer, Integer>, nil] [group, modifier] or nil if unknown
|
||||
def decode_skill_id(skill_id)
|
||||
base_id = skill_id.to_i / 10
|
||||
GAME_SKILL_MAPPING[base_id]
|
||||
end
|
||||
|
||||
##
|
||||
# Calculates the strength value based on quality tier.
|
||||
# Quality 1-5 maps to base_values[0-4] in ArtifactSkill.
|
||||
#
|
||||
# @param group [Integer] Skill group (1, 2, or 3)
|
||||
# @param modifier [Integer] Skill modifier within group
|
||||
# @param quality [Integer] Quality tier (1-5)
|
||||
# @return [Float, Integer, nil] The strength value
|
||||
def calculate_strength(group, modifier, quality)
|
||||
skill = ArtifactSkill.find_skill(group, modifier)
|
||||
return nil unless skill
|
||||
|
||||
base_values = skill.base_values
|
||||
return nil if base_values.blank?
|
||||
|
||||
# Quality 1-5 maps to index 0-4
|
||||
index = (quality - 1).clamp(0, base_values.size - 1)
|
||||
base_values[index]
|
||||
end
|
||||
end
|
||||
|
|
@ -180,6 +180,7 @@ Rails.application.routes.draw do
|
|||
resources :artifacts, only: [:create, :update, :destroy], controller: '/api/v1/collection_artifacts' do
|
||||
collection do
|
||||
post :batch
|
||||
post :import
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
18
db/migrate/20251203221115_add_game_id_to_collection_items.rb
Normal file
18
db/migrate/20251203221115_add_game_id_to_collection_items.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddGameIdToCollectionItems < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# Add game_id to collection_artifacts
|
||||
# This stores the unique instance ID from the game's inventory (the "id" field in game data)
|
||||
add_column :collection_artifacts, :game_id, :string
|
||||
add_index :collection_artifacts, %i[user_id game_id], unique: true, where: 'game_id IS NOT NULL'
|
||||
|
||||
# Add game_id to collection_weapons
|
||||
add_column :collection_weapons, :game_id, :string
|
||||
add_index :collection_weapons, %i[user_id game_id], unique: true, where: 'game_id IS NOT NULL'
|
||||
|
||||
# Add game_id to collection_summons
|
||||
add_column :collection_summons, :game_id, :string
|
||||
add_index :collection_summons, %i[user_id game_id], unique: true, where: 'game_id IS NOT NULL'
|
||||
end
|
||||
end
|
||||
|
|
@ -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_03_211939) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_03_221115) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
|
@ -151,9 +151,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_03_211939) do
|
|||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "reroll_slot"
|
||||
t.string "game_id"
|
||||
t.index ["artifact_id"], name: "index_collection_artifacts_on_artifact_id"
|
||||
t.index ["element"], name: "index_collection_artifacts_on_element"
|
||||
t.index ["user_id", "artifact_id"], name: "index_collection_artifacts_on_user_id_and_artifact_id"
|
||||
t.index ["user_id", "game_id"], name: "index_collection_artifacts_on_user_id_and_game_id", unique: true, where: "(game_id IS NOT NULL)"
|
||||
t.index ["user_id"], name: "index_collection_artifacts_on_user_id"
|
||||
end
|
||||
|
||||
|
|
@ -195,7 +197,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_03_211939) do
|
|||
t.integer "transcendence_step", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "game_id"
|
||||
t.index ["summon_id"], name: "index_collection_summons_on_summon_id"
|
||||
t.index ["user_id", "game_id"], name: "index_collection_summons_on_user_id_and_game_id", unique: true, where: "(game_id IS NOT NULL)"
|
||||
t.index ["user_id", "summon_id"], name: "index_collection_summons_on_user_id_and_summon_id"
|
||||
t.index ["user_id"], name: "index_collection_summons_on_user_id"
|
||||
end
|
||||
|
|
@ -218,7 +222,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_03_211939) do
|
|||
t.integer "element"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "game_id"
|
||||
t.index ["awakening_id"], name: "index_collection_weapons_on_awakening_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"
|
||||
t.index ["weapon_id"], name: "index_collection_weapons_on_weapon_id"
|
||||
|
|
|
|||
Loading…
Reference in a new issue