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:
Justin Edmund 2025-12-03 14:20:21 -08:00
parent 1cbfa90428
commit 6cf85a5b3e
5 changed files with 337 additions and 2 deletions

View file

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

View 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

View file

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

View 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

View file

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