From 6cf85a5b3eb21fffb11777e298a4eb8945597a22 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 14:20:21 -0800 Subject: [PATCH] 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 --- .../api/v1/collection_artifacts_controller.rb | 37 ++- app/services/artifact_import_service.rb | 275 ++++++++++++++++++ config/routes.rb | 1 + ...3221115_add_game_id_to_collection_items.rb | 18 ++ db/schema.rb | 8 +- 5 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 app/services/artifact_import_service.rb create mode 100644 db/migrate/20251203221115_add_game_id_to_collection_items.rb diff --git a/app/controllers/api/v1/collection_artifacts_controller.rb b/app/controllers/api/v1/collection_artifacts_controller.rb index 3415976..11409e4 100644 --- a/app/controllers/api/v1/collection_artifacts_controller.rb +++ b/app/controllers/api/v1/collection_artifacts_controller.rb @@ -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 diff --git a/app/services/artifact_import_service.rb b/app/services/artifact_import_service.rb new file mode 100644 index 0000000..3502e3b --- /dev/null +++ b/app/services/artifact_import_service.rb @@ -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, 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 diff --git a/config/routes.rb b/config/routes.rb index 5940490..7a8d8d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20251203221115_add_game_id_to_collection_items.rb b/db/migrate/20251203221115_add_game_id_to_collection_items.rb new file mode 100644 index 0000000..c353073 --- /dev/null +++ b/db/migrate/20251203221115_add_game_id_to_collection_items.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 76d973a..fa89fb4 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_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"