From 07e5488e0b3d2d04fc612b1d82ecec23ab07a27e Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 17 Sep 2025 05:44:14 -0700 Subject: [PATCH] Add custom page size support via X-Per-Page header - Add page_size helper method to read from X-Per-Page header - Set min (1) and max (100) bounds for page sizes - Update all paginated endpoints to use dynamic page size - Maintain backward compatibility with default sizes --- app/controllers/api/v1/api_controller.rb | 20 + app/controllers/api/v1/parties_controller.rb | 8 +- app/controllers/api/v1/search_controller.rb | 20 +- app/controllers/api/v1/users_controller.rb | 5 +- .../concerns/party_querying_concern.rb | 4 +- config/initializers/cors.rb | 17 +- ...75433_convert_game_raw_columns_to_jsonb.rb | 86 +++ .../20250307080232_drop_game_raw_en_backup.rb | 7 + db/schema.rb | 166 ++++- lib/granblue/parsers/base_parser.rb | 140 ++++ .../parsers/character_skill_parser.rb | 620 ++++++++++++++++++ lib/granblue/parsers/weapon_skill_parser.rb | 101 +++ spec/requests/drag_drop_api_spec.rb | 154 +++++ spec/requests/drag_drop_endpoints_spec.rb | 350 ++++++++++ 14 files changed, 1670 insertions(+), 28 deletions(-) create mode 100644 db/migrate/20250307075433_convert_game_raw_columns_to_jsonb.rb create mode 100644 db/migrate/20250307080232_drop_game_raw_en_backup.rb create mode 100644 lib/granblue/parsers/base_parser.rb create mode 100644 lib/granblue/parsers/character_skill_parser.rb create mode 100644 lib/granblue/parsers/weapon_skill_parser.rb create mode 100644 spec/requests/drag_drop_api_spec.rb create mode 100644 spec/requests/drag_drop_endpoints_spec.rb diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index c2a6628..dfcc9c5 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -9,6 +9,8 @@ module Api ##### Constants COLLECTION_PER_PAGE = 15 SEARCH_PER_PAGE = 10 + MAX_PER_PAGE = 100 + MIN_PER_PAGE = 1 ##### Errors rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response @@ -121,6 +123,24 @@ module Api raise UnauthorizedError unless current_user end + # Returns the requested page size within valid bounds + # Falls back to default if not specified or invalid + # Reads from X-Per-Page header + def page_size(default = COLLECTION_PER_PAGE) + per_page_header = request.headers['X-Per-Page'] + return default unless per_page_header.present? + + requested_size = per_page_header.to_i + return default if requested_size <= 0 + + [[requested_size, MAX_PER_PAGE].min, MIN_PER_PAGE].max + end + + # Returns the requested page size for search operations + def search_page_size + page_size(SEARCH_PER_PAGE) + end + def n_plus_one_detection Prosopite.scan yield diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index 02b938a..e43c2d1 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -144,8 +144,8 @@ module Api # Lists parties based on query parameters. def index query = build_filtered_query(build_common_base_query) - @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) - render_paginated_parties(@parties) + @parties = query.paginate(page: params[:page], per_page: page_size) + render_paginated_parties(@parties, page_size) end # GET /api/v1/parties/favorites @@ -157,8 +157,8 @@ module Api .where(favorites: { user_id: current_user.id }) .distinct query = build_filtered_query(base_query) - @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) - render_paginated_parties(@parties) + @parties = query.paginate(page: params[:page], per_page: page_size) + render_paginated_parties(@parties, page_size) end # Preview Management diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index 9e65c5e..2dbe156 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -82,14 +82,14 @@ module Api end count = characters.length - paginated = characters.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) + paginated = characters.paginate(page: search_params[:page], per_page: search_page_size) render json: CharacterBlueprint.render(paginated, root: :results, meta: { count: count, total_pages: total_pages(count), - per_page: SEARCH_PER_PAGE + per_page: search_page_size }) end @@ -120,14 +120,14 @@ module Api end count = weapons.length - paginated = weapons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) + paginated = weapons.paginate(page: search_params[:page], per_page: search_page_size) render json: WeaponBlueprint.render(paginated, root: :results, meta: { count: count, total_pages: total_pages(count), - per_page: SEARCH_PER_PAGE + per_page: search_page_size }) end @@ -153,14 +153,14 @@ module Api end count = summons.length - paginated = summons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) + paginated = summons.paginate(page: search_params[:page], per_page: search_page_size) render json: SummonBlueprint.render(paginated, root: :results, meta: { count: count, total_pages: total_pages(count), - per_page: SEARCH_PER_PAGE + per_page: search_page_size }) end @@ -241,14 +241,14 @@ module Api end count = skills.length - paginated = skills.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) + paginated = skills.paginate(page: search_params[:page], per_page: search_page_size) render json: JobSkillBlueprint.render(paginated, root: :results, meta: { count: count, total_pages: total_pages(count), - per_page: SEARCH_PER_PAGE + per_page: search_page_size }) end @@ -261,14 +261,14 @@ module Api end count = books.length - paginated = books.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) + paginated = books.paginate(page: search_params[:page], per_page: search_page_size) render json: GuidebookBlueprint.render(paginated, root: :results, meta: { count: count, total_pages: total_pages(count), - per_page: SEARCH_PER_PAGE + per_page: search_page_size }) end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 639615f..afd13dd 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -79,13 +79,14 @@ module Api current_user: current_user, options: { skip_privacy: skip_privacy } ).build - parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE) + current_page_size = page_size + parties = query.paginate(page: params[:page], per_page: current_page_size) count = query.count render json: UserBlueprint.render(@user, view: :profile, root: 'profile', parties: parties, - meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE }, + meta: { count: count, total_pages: (count.to_f / current_page_size).ceil, per_page: current_page_size }, current_user: current_user ) end diff --git a/app/controllers/concerns/party_querying_concern.rb b/app/controllers/concerns/party_querying_concern.rb index da65055..daead85 100644 --- a/app/controllers/concerns/party_querying_concern.rb +++ b/app/controllers/concerns/party_querying_concern.rb @@ -32,7 +32,7 @@ module PartyQueryingConcern end # Renders paginated parties using PartyBlueprint. - def render_paginated_parties(parties) + def render_paginated_parties(parties, per_page = COLLECTION_PER_PAGE) render json: Api::V1::PartyBlueprint.render( parties, view: :preview, @@ -40,7 +40,7 @@ module PartyQueryingConcern meta: { count: parties.total_entries, total_pages: parties.total_pages, - per_page: COLLECTION_PER_PAGE + per_page: per_page }, current_user: current_user ) diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 14c0860..a444ac6 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -8,13 +8,24 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do if Rails.env.production? - origins %w[granblue.team app.granblue.team hensei-web-production.up.railway.app game.granbluefantasy.jp chrome-extension://ahacbogimbikgiodaahmacboojcpdfpf] + origins %w[granblue.team app.granblue.team hensei-web-production.up.railway.app game.granbluefantasy.jp + chrome-extension://ahacbogimbikgiodaahmacboojcpdfpf] else - origins %w[staging.granblue.team 127.0.0.1:1234 game.granbluefantasy.jp chrome-extension://ahacbogimbikgiodaahmacboojcpdfpf] + origins %w[ + localhost:5174 + 127.0.0.1:5174 + localhost:5173 + 127.0.0.1:5173 + staging.granblue.team + 127.0.0.1:1234 + game.granbluefantasy.jp + chrome-extension://ahacbogimbikgiodaahmacboojcpdfpf + ] end resource '*', headers: :any, - methods: %i[get post put patch delete options head] + methods: %i[get post put patch delete options head], + credentials: true end end diff --git a/db/migrate/20250307075433_convert_game_raw_columns_to_jsonb.rb b/db/migrate/20250307075433_convert_game_raw_columns_to_jsonb.rb new file mode 100644 index 0000000..882d074 --- /dev/null +++ b/db/migrate/20250307075433_convert_game_raw_columns_to_jsonb.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class ConvertGameRawColumnsToJsonb < ActiveRecord::Migration[8.0] + def up + tables = %w[characters summons weapons] + + tables.each do |table| + # Create a backup of game_raw_en to prevent data loss + add_column table, :game_raw_en_backup, :text + execute("UPDATE #{table} SET game_raw_en_backup = game_raw_en WHERE game_raw_en IS NOT NULL") + + # Verify backup integrity + backup_validation = execute(<<~SQL).first + SELECT COUNT(*) AS missing_backups + FROM #{table} + WHERE game_raw_en IS NOT NULL#{' '} + AND game_raw_en_backup IS NULL + SQL + + if backup_validation['missing_backups'].to_i.positive? + raise ActiveRecord::MigrationError, "Backup failed for #{table}. Aborting migration." + end + + # Convert game_raw_en with data validation + begin + execute("ALTER TABLE #{table} ALTER COLUMN game_raw_en TYPE JSONB USING game_raw_en::JSONB") + rescue StandardError => e + # Find and report problematic rows + create_invalid_rows_table(table) + invalid_count = execute("SELECT COUNT(*) FROM invalid_#{table}_rows").first['count'] + + raise ActiveRecord::MigrationError, <<~ERROR + Failed to convert game_raw_en in #{table} to JSONB. + #{invalid_count} rows contain invalid JSON. + Original error: #{e.message} + See temporary table invalid_#{table}_rows for details. + ERROR + end + + # Simply convert game_raw_jp (empty column) + execute("ALTER TABLE #{table} ALTER COLUMN game_raw_jp TYPE JSONB USING COALESCE(game_raw_jp::JSONB, 'null'::JSONB)") + + # Add comment to indicate column purpose + execute("COMMENT ON COLUMN #{table}.game_raw_en IS 'JSON data from game (English)'") + execute("COMMENT ON COLUMN #{table}.game_raw_jp IS 'JSON data from game (Japanese)'") + end + + # Leave a note about backup columns in migration output + say 'Migration successful. Backup columns (game_raw_en_backup) remain for verification.' + say 'Run a separate migration to remove backup columns after verification.' + end + + def down + tables = %w[characters summons weapons] + + tables.each do |table| + # Check if we can restore from backup + if column_exists?(table, :game_raw_en_backup) + say "Restoring #{table}.game_raw_en from backup..." + execute("UPDATE #{table} SET game_raw_en = game_raw_en_backup WHERE game_raw_en_backup IS NOT NULL") + remove_column table, :game_raw_en_backup + end + + # Convert both columns back to TEXT + execute("ALTER TABLE #{table} ALTER COLUMN game_raw_en TYPE TEXT") + execute("ALTER TABLE #{table} ALTER COLUMN game_raw_jp TYPE TEXT") + end + end + + private + + def create_invalid_rows_table(table) + execute(<<~SQL) + CREATE TEMPORARY TABLE invalid_#{table}_rows AS + SELECT id, game_raw_en#{' '} + FROM #{table} + WHERE game_raw_en IS NOT NULL + AND pg_typeof(game_raw_en) = 'text'::regtype + AND ( + TRIM(game_raw_en) = ''#{' '} + OR#{' '} + (game_raw_en::JSONB) IS NULL + ); + SQL + end +end diff --git a/db/migrate/20250307080232_drop_game_raw_en_backup.rb b/db/migrate/20250307080232_drop_game_raw_en_backup.rb new file mode 100644 index 0000000..73acce0 --- /dev/null +++ b/db/migrate/20250307080232_drop_game_raw_en_backup.rb @@ -0,0 +1,7 @@ +class DropGameRawEnBackup < ActiveRecord::Migration[8.0] + def change + remove_column :characters, :game_raw_en_backup + remove_column :summons, :game_raw_en_backup + remove_column :weapons, :game_raw_en_backup + end +end diff --git a/db/schema.rb b/db/schema.rb index a69bf75..2fe0196 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_03_01_143956) do +ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -31,6 +31,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.integer "order", default: 0, null: false end + create_table "character_skills", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "character_granblue_id", null: false + t.uuid "skill_id", null: false + t.integer "position", null: false + t.integer "unlock_level" + t.integer "improve_level" + t.uuid "alt_skill_id" + t.text "alt_condition" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["alt_skill_id"], name: "index_character_skills_on_alt_skill_id" + t.index ["character_granblue_id", "position"], name: "index_character_skills_on_character_granblue_id_and_position" + t.index ["character_granblue_id"], name: "index_character_skills_on_character_granblue_id" + t.index ["skill_id"], name: "index_character_skills_on_skill_id" + end + create_table "characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name_en" t.string "name_jp" @@ -68,12 +84,27 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.string "nicknames_en", default: [], null: false, array: true t.string "nicknames_jp", default: [], null: false, array: true t.text "wiki_raw" - t.text "game_raw_en" - t.text "game_raw_jp" + t.jsonb "game_raw_en", comment: "JSON data from game (English)" + t.jsonb "game_raw_jp", comment: "JSON data from game (Japanese)" + t.text "game_raw_en_backup" t.index ["granblue_id"], name: "index_characters_on_granblue_id" t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin end + create_table "charge_attacks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "owner_id", null: false + t.string "owner_type", null: false + t.uuid "skill_id", null: false + t.integer "uncap_level" + t.uuid "alt_skill_id" + t.text "alt_condition" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["alt_skill_id"], name: "index_charge_attacks_on_alt_skill_id" + t.index ["owner_type", "owner_id", "uncap_level"], name: "idx_on_owner_type_owner_id_uncap_level_b37b556440" + t.index ["skill_id"], name: "index_charge_attacks_on_skill_id" + end + create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t| end @@ -83,6 +114,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.index ["filename"], name: "index_data_versions_on_filename", unique: true end + create_table "effects", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name_en", null: false + t.string "name_jp" + t.text "description_en" + t.text "description_jp" + t.string "icon_path" + t.integer "effect_type", null: false + t.string "effect_class" + t.uuid "effect_family_id" + t.boolean "stackable", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["effect_class"], name: "index_effects_on_effect_class" + t.index ["name_en"], name: "index_effects_on_name_en" + end + create_table "favorites", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id" t.uuid "party_id" @@ -103,6 +150,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.boolean "holiday" t.string "drawable_type" t.uuid "drawable_id" + t.boolean "classic_ii", default: false + t.boolean "collab", default: false t.index ["drawable_id"], name: "index_gacha_on_drawable_id", unique: true t.index ["drawable_type", "drawable_id"], name: "index_gacha_on_drawable" end @@ -375,6 +424,51 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.uuid "group_id" end + create_table "skill_effects", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "skill_id", null: false + t.uuid "effect_id", null: false + t.integer "target_type" + t.integer "duration_type" + t.integer "duration_value" + t.text "condition" + t.integer "chance" + t.decimal "value" + t.decimal "cap" + t.boolean "local", default: true + t.boolean "permanent", default: false + t.boolean "undispellable", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["effect_id"], name: "index_skill_effects_on_effect_id" + t.index ["skill_id", "effect_id", "target_type"], name: "index_skill_effects_on_skill_id_and_effect_id_and_target_type" + t.index ["skill_id"], name: "index_skill_effects_on_skill_id" + end + + create_table "skill_values", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "skill_id", null: false + t.integer "level", default: 1, null: false + t.decimal "value" + t.string "text_value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["skill_id", "level"], name: "index_skill_values_on_skill_id_and_level", unique: true + t.index ["skill_id"], name: "index_skill_values_on_skill_id" + end + + create_table "skills", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name_en", null: false + t.string "name_jp" + t.text "description_en" + t.text "description_jp" + t.integer "border_type" + t.integer "cooldown" + t.integer "skill_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name_en"], name: "index_skills_on_name_en" + t.index ["skill_type"], name: "index_skills_on_skill_type" + end + create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "user_id", null: false t.string "guild_ids", null: false, array: true @@ -389,6 +483,37 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.index ["user_id"], name: "index_sparks_on_user_id", unique: true end + create_table "summon_auras", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "summon_granblue_id", null: false + t.text "description_en" + t.text "description_jp" + t.integer "aura_type" + t.integer "boost_type" + t.string "boost_target" + t.decimal "boost_value" + t.integer "uncap_level" + t.text "condition" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["summon_granblue_id", "aura_type", "uncap_level"], name: "idx_on_summon_granblue_id_aura_type_uncap_level_631fc8f523" + t.index ["summon_granblue_id"], name: "index_summon_auras_on_summon_granblue_id" + end + + create_table "summon_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "summon_granblue_id", null: false + t.uuid "skill_id", null: false + t.integer "cooldown" + t.integer "uncap_level" + t.uuid "alt_skill_id" + t.text "alt_condition" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["alt_skill_id"], name: "index_summon_calls_on_alt_skill_id" + t.index ["skill_id"], name: "index_summon_calls_on_skill_id" + t.index ["summon_granblue_id", "uncap_level"], name: "index_summon_calls_on_summon_granblue_id_and_uncap_level" + t.index ["summon_granblue_id"], name: "index_summon_calls_on_summon_granblue_id" + end + create_table "summons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name_en" t.string "name_jp" @@ -424,8 +549,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.string "nicknames_en", default: [], null: false, array: true t.string "nicknames_jp", default: [], null: false, array: true t.text "wiki_raw" - t.text "game_raw_en" - t.text "game_raw_jp" + t.jsonb "game_raw_en", comment: "JSON data from game (English)" + t.jsonb "game_raw_jp", comment: "JSON data from game (Japanese)" t.index ["granblue_id"], name: "index_summons_on_granblue_id" t.index ["name_en"], name: "index_summons_on_name_en", opclass: :gin_trgm_ops, using: :gin end @@ -464,6 +589,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.integer "series", default: [], null: false, array: true end + create_table "weapon_skills", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "weapon_granblue_id", null: false + t.uuid "skill_id", null: false + t.integer "position", null: false + t.string "skill_modifier" + t.string "skill_series" + t.string "skill_size" + t.integer "unlock_level" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["skill_id"], name: "index_weapon_skills_on_skill_id" + t.index ["skill_series"], name: "index_weapon_skills_on_skill_series" + t.index ["weapon_granblue_id", "position"], name: "index_weapon_skills_on_weapon_granblue_id_and_position" + t.index ["weapon_granblue_id"], name: "index_weapon_skills_on_weapon_granblue_id" + end + create_table "weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name_en" t.string "name_jp" @@ -503,13 +644,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do t.integer "series" t.integer "new_series" t.text "wiki_raw" - t.text "game_raw_en" - t.text "game_raw_jp" + t.jsonb "game_raw_en", comment: "JSON data from game (English)" + t.jsonb "game_raw_jp", comment: "JSON data from game (Japanese)" t.index ["granblue_id"], name: "index_weapons_on_granblue_id" t.index ["name_en"], name: "index_weapons_on_name_en", opclass: :gin_trgm_ops, using: :gin t.index ["recruits"], name: "index_weapons_on_recruits" end + add_foreign_key "character_skills", "skills" + add_foreign_key "character_skills", "skills", column: "alt_skill_id" + add_foreign_key "charge_attacks", "skills" + add_foreign_key "charge_attacks", "skills", column: "alt_skill_id" + add_foreign_key "effects", "effects", column: "effect_family_id" add_foreign_key "favorites", "parties" add_foreign_key "favorites", "users" add_foreign_key "grid_characters", "awakenings" @@ -536,6 +682,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do add_foreign_key "parties", "raids" add_foreign_key "parties", "users" add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey" + add_foreign_key "skill_effects", "effects", name: "fk_skill_effects_effects" + add_foreign_key "skill_effects", "skills", name: "fk_skill_effects_skills" + add_foreign_key "skill_values", "skills" + add_foreign_key "summon_calls", "skills" + add_foreign_key "summon_calls", "skills", column: "alt_skill_id" add_foreign_key "weapon_awakenings", "awakenings" add_foreign_key "weapon_awakenings", "weapons" + add_foreign_key "weapon_skills", "skills" end diff --git a/lib/granblue/parsers/base_parser.rb b/lib/granblue/parsers/base_parser.rb new file mode 100644 index 0000000..2a3bb31 --- /dev/null +++ b/lib/granblue/parsers/base_parser.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module Granblue + module Parsers + class BaseParser + def initialize(entity, debug: false, use_local: false) + @entity = entity + @wiki = Granblue::Parsers::Wiki.new + @debug = debug || false + @use_local = use_local || false + end + + def fetch(save: false) + # Use local data if available and requested + if @use_local && @entity.wiki_raw.present? + wikitext = @entity.wiki_raw + return handle_fetch_success(wikitext, save) + end + + # Otherwise fetch from wiki + response = fetch_wiki_info + return false if response.nil? + + redirect = handle_redirected_string(response) + return fetch(save: save) unless redirect.nil? + + handle_fetch_success(response, save) + end + + protected + + def parse_string(string) + lines = string.split("\n") + data = {} + stop_loop = false + template_data = {} + + lines.each do |line| + next if stop_loop + + if line.include?('Gameplay Notes') + stop_loop = true + next + end + + # Template handling + if line.start_with?('{{') + template_data = extract_template_info(line) + data[:template] = template_data[:name] if template_data[:name] + next + end + + # Standard key-value pairs + next unless line[0] == '|' && line.size > 2 + + key, value = line[1..].split('=', 2).map(&:strip) + data[key] = value if value && !value.match?(/\A\{\{\{.*\|\}\}\}\z/) + end + + data + end + + def extract_template_info(line) + result = { name: nil } + + substr = line[2..].strip! || line[2..] + + # Skip disallowed templates + disallowed = %w[#vardefine #lsth About] + return result if substr.start_with?(*disallowed) + + # Extract entity type template name + entity_types = %w[Character Weapon Summon] + entity_types.each do |type| + next unless substr.start_with?(type) + + substr = substr.split('|').first + result[:name] = substr if substr != type + break + end + + result + end + + def handle_redirected_string(response) + redirect = extract_redirected_string(response) + return unless redirect + + @entity.wiki_en = redirect + return unless @entity.save! + + ap "Saved new wiki_en value: #{redirect}" if @debug + redirect + end + + def extract_redirected_string(string) + string.match(/#REDIRECT \[\[(.*?)\]\]/)&.captures&.first + end + + def handle_fetch_success(response, save) + @entity.wiki_raw = response + @entity.save! + + ap "Successfully fetched info for #{@entity.wiki_en}" if @debug + + extracted = parse_string(response) + + # Handle template + if extracted[:template] + template = @wiki.fetch("Template:#{extracted[:template]}") + extracted.merge!(parse_string(template)) + end + + info = parse(extracted) + persist(info) if save + true + end + + def fetch_wiki_info + @wiki.fetch(@entity.wiki_en) + rescue WikiError => e + ap "Error fetching #{e.page}: #{e.message}" if @debug + nil + end + + # Must be implemented by subclasses + def parse(hash) + raise NotImplementedError + end + + def persist(info) + raise NotImplementedError + end + + def parse_date(date_str) + Date.parse(date_str) unless date_str.blank? + end + end + end +end diff --git a/lib/granblue/parsers/character_skill_parser.rb b/lib/granblue/parsers/character_skill_parser.rb new file mode 100644 index 0000000..988c59a --- /dev/null +++ b/lib/granblue/parsers/character_skill_parser.rb @@ -0,0 +1,620 @@ +# frozen_string_literal: true + +module Granblue + module Parsers + class CharacterSkillParser + require 'nokogiri' + + def initialize(character) + @character = character + @wiki_data = begin + # Don't try to parse as JSON - parse as MediaWiki format instead + extract_wiki_data(@character.wiki_raw) + rescue StandardError => e + Rails.logger.error "Error parsing wiki raw data: #{e.message}" + nil + end + @game_data = @character.game_raw_en + end + + def extract_wiki_data(wikitext) + return nil unless wikitext.present? + + data = {} + # Extract basic character info from template + wikitext.scan(/\|(\w+)=([^\n|]+)/) do |key, value| + data[key] = value.strip + end + + # Extract ability count + if match = wikitext.match(/\|abilitycount=\s*(\d+)/) + data['abilitycount'] = match[1] + end + + # Extract individual abilities + skill_count = data['abilitycount'].to_i + (1..skill_count).each do |position| + # Extract ability icon, name, cooldown, etc. + extract_skill_data(wikitext, position, data) + end + + # Extract charge attack data + extract_ougi_data(wikitext, data) + + data + end + + def extract_skill_data(wikitext, position, data) + prefix = "a#{position}" + + # Extract skill name + if match = wikitext.match(/\|#{prefix}_name=\s*([^\n|]+)/) + data["#{prefix}_name"] = match[1].strip + end + + # Extract skill cooldown + if match = wikitext.match(/\|#{prefix}_cd=\s*\{\{InfoCd[^}]*cooldown=(\d+)[^}]*\}\}/) + data["#{prefix}_cd"] = match[1] + end + + # Extract skill description using InfoDes template + if match = wikitext.match(/\|#{prefix}_effdesc=\s*\{\{InfoDes\|num=\d+\|des=([^|]+)(?:\|[^}]+)?\}\}/) + data["#{prefix}_effdesc"] = match[1].strip + end + + # Check for alt version indicator + data["#{prefix}_option"] = 'alt' if wikitext.match(/\|#{prefix}_option=alt/) + + # Extract obtained level + if (match = wikitext.match(/\|#{prefix}_oblevel=\s*\{\{InfoOb\|obtained=(\d+)(?:\|[^}]+)?\}\}/)) + data["#{prefix}_oblevel"] = "obtained=#{match[1]}" + end + + # Extract enhanced level if present + if (match = wikitext.match(/\|#{prefix}_oblevel=\s*\{\{InfoOb\|obtained=\d+\|enhanced=(\d+)(?:\|[^}]+)?\}\}/)) + data["#{prefix}_oblevel"] += "|enhanced=#{match[1]}" + end + end + + def extract_ougi_data(wikitext, data) + # Extract charge attack name + if (match = wikitext.match(/\|ougi_name=\s*([^\n|]+)/)) + data['ougi_name'] = match[1].strip + end + + # Extract charge attack description + if (match = wikitext.match(/\|ougi_desc=\s*([^\n|]+)/)) + data['ougi_desc'] = match[1].strip + end + + # Extract FLB/ULB charge attack details if present + if (match = wikitext.match(/\|ougi2_name=\s*([^\n|]+)/)) + data['ougi2_name'] = match[1].strip + end + + return unless (match = wikitext.match(/\|ougi2_desc=\s*([^\n|]+)/)) + + data['ougi2_desc'] = match[1].strip + end + + def parse_and_save + return unless @wiki_data && @game_data + + # Parse and save skills + parse_skills + + # Parse and save charge attack + parse_charge_attack + + # Return success status + true + rescue StandardError => e + Rails.logger.error "Error parsing skills for character #{@character.name_en}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + false + end + + private + + def parse_skills + # Get ability data from game data + game_abilities = @game_data['ability'] || {} + ap 'Game' + ap game_abilities + + # Get skill count from wiki data + skill_count = @wiki_data['abilitycount'].to_i + ap 'Wiki' + ap skill_count + + # Process each skill + (1..skill_count).each do |position| + game_skill = game_abilities[position.to_s] + next unless game_skill + + # Create or find skill + skill = Skill.find_or_initialize_by( + name_en: game_skill['name_en'] || game_skill['name'], + skill_type: Skill.skill_types[:character] + ) + + # Set skill attributes + skill.name_jp = game_skill['name'] if game_skill['name'].present? + skill.description_en = game_skill['comment_en'] || game_skill['comment'] + skill.description_jp = game_skill['comment'] if game_skill['comment'].present? + skill.border_type = extract_border_type(game_skill) + skill.cooldown = game_skill['recast'].to_i if game_skill['recast'].present? + + # Save skill + skill.save! + + # Wiki data for skill + wiki_skill_key = "a#{position}" + wiki_skill = @wiki_data[wiki_skill_key] || {} + + # Create character skill connection + character_skill = CharacterSkill.find_or_initialize_by( + character_granblue_id: @character.granblue_id, + position: position + ) + + character_skill.skill = skill + character_skill.unlock_level = wiki_skill["a#{position}_oblevel"]&.match(/obtained=(\d+)/)&.captures&.first&.to_i || 1 + character_skill.improve_level = wiki_skill["a#{position}_oblevel"]&.match(/enhanced=(\d+)/)&.captures&.first&.to_i + + # Check for alt version + if game_skill['display_action_ability_info']&.dig('action_ability')&.any? + # Handle alt version of skill + alt_action = game_skill['display_action_ability_info']['action_ability'].first + + alt_skill = Skill.find_or_initialize_by( + name_en: alt_action['name_en'] || alt_action['name'], + skill_type: Skill.skill_types[:character] + ) + + alt_skill.name_jp = alt_action['name'] if alt_action['name'].present? + alt_skill.description_en = alt_action['comment_en'] || alt_action['comment'] + alt_skill.description_jp = alt_action['comment'] if alt_action['comment'].present? + alt_skill.border_type = extract_border_type(alt_action) + alt_skill.cooldown = alt_action['recast'].to_i if alt_action['recast'].present? + + alt_skill.save! + + character_skill.alt_skill = alt_skill + + # Parse condition for alt version + if wiki_skill['alt_condition'].present? + character_skill.alt_condition = wiki_skill['alt_condition'] + elsif game_skill['comment_en']&.include?('when') + # Try to extract condition from comment + if match = game_skill['comment_en'].match(/\(.*?when\s+(.*?)\s*(?::|$)/i) + character_skill.alt_condition = match[1] + end + end + end + + character_skill.save! + + # Parse and save effects + parse_effects_for_skill(skill, game_skill) + + # If alt skill exists, parse its effects too + if character_skill.alt_skill + alt_action = game_skill['display_action_ability_info']['action_ability'].first + parse_effects_for_skill(character_skill.alt_skill, alt_action) + end + end + end + + def parse_charge_attack + ap 'Parsing charge attack...' + # Get charge attack data from wiki and game + wiki_ougi = { + 'name' => @wiki_data['ougi_name'], + 'desc' => @wiki_data['ougi_desc'] + } + + # ap @game_data + game_ougi = @game_data['special_skill'] + ap 'Game ougi:' + ap game_ougi + return unless game_ougi + + puts 'Wiki' + puts wiki_ougi + puts 'Game' + puts game_ougi + + # Create skill for charge attack + skill = Skill.find_or_initialize_by( + name_en: wiki_ougi['name'] || game_ougi['name'], + skill_type: Skill.skill_types[:charge_attack] + ) + + skill.name_jp = game_ougi['name'] if game_ougi['name'].present? + skill.description_en = wiki_ougi['desc'] || game_ougi['comment'] + skill.description_jp = game_ougi['comment'] if game_ougi['comment'].present? + skill.save! + + # Create charge attack record + charge_attack = ChargeAttack.find_or_initialize_by( + owner_id: @character.granblue_id, + owner_type: 'character', + uncap_level: 0 + ) + + charge_attack.skill = skill + charge_attack.save! + + # Parse effects for charge attack + parse_effects_for_charge_attack(skill, wiki_ougi['desc'], game_ougi) + + # If there are uncapped charge attacks + return unless @wiki_data['ougi2_name'].present? + + # Process 5* uncap charge attack + alt_skill = Skill.find_or_initialize_by( + name_en: @wiki_data['ougi2_name'], + skill_type: Skill.skill_types[:charge_attack] + ) + + alt_skill.description_en = @wiki_data['ougi2_desc'] + alt_skill.save! + + # Create alt charge attack record + alt_charge_attack = ChargeAttack.find_or_initialize_by( + owner_id: @character.granblue_id, + owner_type: 'character', + uncap_level: 4 # 5* uncap + ) + + alt_charge_attack.skill = alt_skill + alt_charge_attack.save! + + # Parse effects for alt charge attack + parse_effects_for_charge_attack(alt_skill, @wiki_data['ougi2_desc'], nil) + end + + def parse_effects_for_skill(skill, game_skill) + # Look for buff/debuff details + if game_skill['ability_detail'].present? + # Process buffs + if game_skill['ability_detail']['buff'].present? + game_skill['ability_detail']['buff'].each do |buff_data| + create_effect_from_game_data(skill, buff_data, :buff) + end + end + + # Process debuffs + if game_skill['ability_detail']['debuff'].present? + game_skill['ability_detail']['debuff'].each do |debuff_data| + create_effect_from_game_data(skill, debuff_data, :debuff) + end + end + end + + # Also try to extract effects from description + extract_effects_from_description(skill, game_skill['comment_en'] || game_skill['comment']) + end + + def parse_effects_for_charge_attack(skill, description, game_ougi) + # Extract effects from charge attack description + extract_effects_from_description(skill, description) + + # If we have game data, try to extract more details + return unless game_ougi && game_ougi['comment'].present? + + extract_effects_from_description(skill, game_ougi['comment']) + end + + def create_effect_from_game_data(skill, effect_data, effect_type) + # Extract effect name and status code + status = effect_data['status'] + detail = effect_data['detail'] + effect_duration = effect_data['effect'] + + # Get effect class (normalized type) from the detail + effect_class = normalize_effect_class(detail) + + # Create or find effect + effect = Effect.find_or_initialize_by( + name_en: detail, + effect_type: Effect.effect_types[effect_type] + ) + + effect.effect_class = effect_class + effect.save! + + # Create skill effect connection + skill_effect = SkillEffect.find_or_initialize_by( + skill: skill, + effect: effect + ) + + # Figure out target type + target_type = determine_target_type(skill, detail) + skill_effect.target_type = target_type + + # Figure out duration + duration_info = parse_duration(effect_duration) + skill_effect.duration_type = duration_info[:type] + skill_effect.duration_value = duration_info[:value] + + # Other attributes + skill_effect.value = extract_value_from_detail(detail) + skill_effect.cap = extract_cap_from_detail(detail) + skill_effect.permanent = effect_duration.blank? || effect_duration.downcase == 'permanent' + skill_effect.undispellable = detail.include?("Can't be removed") + + skill_effect.save! + end + + def extract_effects_from_description(skill, description) + return unless description.present? + + # Look for status effects in the description with complex pattern matching + status_pattern = /\{\{status\|([^|}]+)(?:\|([^}]+))?\}\}/ + + description.scan(status_pattern).each do |matches| + status_name = matches[0].strip + attrs_text = matches[1] + + # Create effect + effect = Effect.find_or_initialize_by( + name_en: status_name, + effect_type: determine_effect_type(status_name) + ) + + effect.effect_class = normalize_effect_class(status_name) + effect.save! + + # Create skill effect with attributes + skill_effect = SkillEffect.find_or_initialize_by( + skill: skill, + effect: effect + ) + + # Parse attributes from the status tag + if attrs_text.present? + attrs = {} + + # Extract duration (t=X) + if duration_match = attrs_text.match(/t=([^|]+)/) + attrs[:duration] = duration_match[1] + end + + # Extract value (a=X) + if value_match = attrs_text.match(/a=([^|%]+)/) + attrs[:value] = value_match[1] + end + + # Extract cap + if cap_match = attrs_text.match(/cap=(\d+)/) + attrs[:cap] = cap_match[1] + end + + # Apply extracted attributes + skill_effect.target_type = determine_target_type(skill, status_name) + skill_effect.value = attrs[:value].to_f if attrs[:value].present? + skill_effect.cap = attrs[:cap].to_i if attrs[:cap].present? + + # Parse duration + if attrs[:duration].present? + duration_info = parse_duration(attrs[:duration]) + skill_effect.duration_type = duration_info[:type] + skill_effect.duration_value = duration_info[:value] + end + + skill_effect.undispellable = attrs_text.include?("can't be removed") + end + + skill_effect.save! + end + end + + def extract_border_type(skill_data) + # Map class_name to border type + class_name = skill_data['class_name'] + + if class_name.nil? + nil + elsif class_name.end_with?('_1') + Skill.border_types[:damage] + elsif class_name.end_with?('_2') + Skill.border_types[:healing] + elsif class_name.end_with?('_3') + Skill.border_types[:buff] + elsif class_name.end_with?('_4') + Skill.border_types[:debuff] + elsif class_name.end_with?('_5') + Skill.border_types[:field] + else + nil + end + end + + def normalize_effect_class(detail) + # Map common effect descriptions to standardized classes + return nil unless detail.present? + + detail = detail.downcase + + if detail.include?("can't attack") || detail.include?("can't act") || detail.include?('actions for') || detail.include?('actions are sealed') + 'cant_act' + elsif detail.include?('hp is lowered on every turn') && !detail.include?('putrefied') + 'poison' + elsif detail.include?('putrefied') || detail.include?('hp is lowered on every turn based on') + 'poison_strong' + elsif detail.include?('atk is boosted based on how low hp is') || detail.include?('jammed') + 'jammed' + elsif detail.include?('veil') || detail.include?('debuffs will be nullified') + 'veil' + elsif detail.include?('mirror') || detail.include?('next attack will miss') + 'mirror_image' + elsif detail.match?(/dodge.+hit|taking less dmg/i) + 'repel' + elsif detail.include?('shield') || detail.include?('ineffective for a fixed amount') + 'shield' + elsif detail.include?('counter') && detail.include?('dodge') + 'counter_on_dodge' + elsif detail.include?('counter') && detail.include?('dmg') + 'counter_on_damage' + elsif detail.include?('boost to triple attack') || detail.include?('triple attack rate') + 'ta_up' + elsif detail.include?('boost to double attack') || detail.include?('double attack rate') + 'da_up' + elsif detail.include?('boost to charge bar') || detail.include?('charge boost') + 'charge_bar_boost' + elsif detail.include?('drain') || detail.include?('absorbed to hp') + 'drain' + elsif detail.include?('bonus') && detail.include?('dmg') + 'echo' + elsif detail.match?(/atk is (?:sharply )?boosted/i) && !detail.include?('based on') + 'atk_up' + elsif detail.match?(/def is (?:sharply )?boosted/i) && !detail.include?('based on') + 'def_up' + else + # Create a slug from the first few words + detail.split(/\s+/).first(3).join('_').gsub(/[^a-z0-9_]/i, '').downcase + end + end + + def determine_effect_type(name) + name = name.downcase + + if name.include?('down') || name.include?('lower') || name.include?('hit') || name.include?('reduced') || + name.include?('blind') || name.include?('petrif') || name.include?('paralyze') || name.include?('stun') || + name.include?('charm') || name.include?('poison') || name.include?('putrefied') || name.include?('sleep') || + name.include?('fear') || name.include?('delay') + Effect.effect_types[:debuff] + else + Effect.effect_types[:buff] + end + end + + def determine_target_type(skill, detail) + # Try to determine target type from skill and detail + if detail.downcase.include?('all allies') + SkillEffect.target_types[:all_allies] + elsif detail.downcase.include?('all foes') + SkillEffect.target_types[:all_enemies] + elsif detail.downcase.include?('caster') || detail.downcase.include?('own ') + SkillEffect.target_types[:self] + elsif skill.border_type == Skill.border_types[:buff] || detail.downcase.include?('allies') || detail.downcase.include?('party') + SkillEffect.target_types[:ally] + elsif skill.border_type == Skill.border_types[:debuff] || detail.downcase.include?('foe') || detail.downcase.include?('enemy') + SkillEffect.target_types[:enemy] + elsif determine_effect_type(detail) == Effect.effect_types[:buff] + # Default + SkillEffect.target_types[:self] + else + SkillEffect.target_types[:enemy] + end + end + + def parse_duration(duration_text) + return { type: SkillEffect.duration_types[:indefinite], value: nil } unless duration_text.present? + + duration_text = duration_text.downcase + + if duration_text.include?('turn') + # Parse turns + turns = duration_text.scan(/(\d+(?:\.\d+)?)(?:\s*-)?\s*turn/).flatten.first + { + type: SkillEffect.duration_types[:turns], + value: turns.to_f + } + elsif duration_text.include?('sec') + # Parse seconds + seconds = duration_text.scan(/(\d+)(?:\s*-)?\s*sec/).flatten.first + { + type: SkillEffect.duration_types[:seconds], + value: seconds.to_i + } + elsif duration_text.include?('time') || duration_text.include?('hit') + # Parse one-time + { type: SkillEffect.duration_types[:one_time], value: nil } + else + # Default to indefinite + { type: SkillEffect.duration_types[:indefinite], value: nil } + end + end + + def parse_status_attributes(attr_string) + result = { + value: nil, + cap: nil, + duration: nil, + chance: 100, # Default + options: [] + } + + # Split attributes + attrs = attr_string.split('|') + + attrs.each do |attr| + if attr.include?('=') + key, value = attr.split('=', 2) + key = key.strip + value = value.strip + + case key + when 'a' + # Value (amount) + result[:value] = if value.end_with?('%') + value.delete('%').to_f + else + value + end + when 'cap' + # Cap + result[:cap] = value.gsub(/[^\d]/, '').to_i + when 't' + # Duration + result[:duration] = value + when 'acc' + # Accuracy + result[:chance] = if value == 'Guaranteed' + 100 + else + value.delete('%').to_i + end + else + # Other options + result[:options] << "#{key}=#{value}" + end + elsif attr == 'i' + # Simple attributes like "n=1" or just "i" + result[:duration] = 'indefinite' + elsif attr.start_with?('n=') + # Number of hits/times + # Store in options + result[:options] << attr.strip + else + result[:options] << attr.strip + end + end + + result + end + + def extract_value_from_detail(detail) + # Extract numeric value from detail text + if match = detail.match(/(\d+(?:\.\d+)?)%/) + match[1].to_f + else + nil + end + end + + def extract_cap_from_detail(detail) + # Extract cap from detail text + if match = detail.match(/cap(?:ped)?\s*(?:at|:)\s*(\d+(?:,\d+)*)/) + match[1].gsub(',', '').to_i + else + nil + end + end + end + end +end diff --git a/lib/granblue/parsers/weapon_skill_parser.rb b/lib/granblue/parsers/weapon_skill_parser.rb new file mode 100644 index 0000000..8120a89 --- /dev/null +++ b/lib/granblue/parsers/weapon_skill_parser.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Granblue + module Parsers + class WeaponSkillParser + OPTIMUS_AURAS = %w[ + Fire Hellfire Inferno + Water Tsunami Hoarfrost + Wind Whirlwind Ventosus + Earth Mountain Terra + Light THunder Zion + Dark Hatred Oblivion + ].freeze + + OMEGA_AURAS = %w[ + Ironflame Oceansoul Stormwyrm Lifetree Knightcode Mistfall + ] + + UNBOOSTABLE_AURAS = %w[ + Scarlet Cobalt Jade Amber Golden Graphite + ].freeze + + # These skills are boostable by aura + BOOSTABLE_SKILLS = %w[ + Abandon Aegis Apotheosis Auspice Betrayal Bladeshield Bloodshed + Celere Clarity Deathstrike Demolishment Devastation Dual-Edge + Encouragement Enmity Essence Fandango Garrison Glory Grace + Haunt Healing Heed Heroism Impalement Insignia Majesty Might + Mystery Precocity Primacy Progression Resolve Restraint Sapience + Sentence Spearhead Stamina Stratagem Sweep Tempering Trituration + Trium Truce Tyranny Verity Verve + ].freeze + + # These skills have flat values and are not boostable by aura + UNBOOSTABLE_SKILLS = %w[ + Arts Ascendancy "Beast Essence" Blessing Blow "Chain Force" + Charge Convergence Craft Enforcement Excelsior Exertion Fortified + Fortitude Frailty "Grand Epic" Initiation Marvel "Omega Exalto" + "Optimus Exalto" Pact Persistence "Preemptive Barrier" + "Preemptive Blade" "Preemptive Wall" Quenching Quintessence + Resonator "Sephira Maxi" "Sephira Soul" "Sephira Tek" Sovereign + Spectacle Strike "Striking Art" Supremacy Surge Swashbuckler + "True Supremacy" Valuables Vitality Vivification Voltage + Wrath "Zenith Art" "Zenith Strike" + ] + + # These skills can be boostable or unboostable depending on the source + DEPENDENT_SKILLS = %w[ + Crux + ].freeze + + def self.parse(skill_name) + return { aura: nil, skill_type: nil, skill_name: skill_name } if skill_name.blank? + + # Handle standard format: "Aura's Skill [I-IV]" + if match = skill_name.match(/^(.*?)'s\s+(.+?)(?:\s+(I{1,3}V?|IV))?$/) + aura = match[1] + skill = match[2] + numeral = match[3] + + skill_with_numeral = numeral ? "#{skill} #{numeral}" : skill + + # Check if aura and skill are in known lists + if KNOWN_AURAS.include?(aura) && KNOWN_SKILLS.include?(skill) + return { aura: aura, skill_type: skill, skill_name: skill_name } + end + + return { aura: nil, skill_type: 'Special', skill_name: skill_name } + + end + + # Handle two-word format without possessive: "Aura Skill" + if skill_name.split.size == 2 + parts = skill_name.split + aura = parts[0] + skill = parts[1] + + # Check if aura and skill are in known lists + if KNOWN_AURAS.include?(aura) && KNOWN_SKILLS.include?(skill) + return { aura: aura, skill_type: skill, skill_name: skill_name } + end + + return { aura: nil, skill_type: 'Special', skill_name: skill_name } + + end + + # Fallback for special cases + { aura: nil, skill_type: 'Special', skill_name: skill_name } + end + + # Method to extend dictionaries + def self.add_aura(aura) + KNOWN_AURAS << aura unless KNOWN_AURAS.include?(aura) + end + + def self.add_skill(skill) + KNOWN_SKILLS << skill unless KNOWN_SKILLS.include?(skill) + end + end + end +end diff --git a/spec/requests/drag_drop_api_spec.rb b/spec/requests/drag_drop_api_spec.rb new file mode 100644 index 0000000..5101dab --- /dev/null +++ b/spec/requests/drag_drop_api_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Drag Drop API', type: :request do + # Create minimal test data without relying on seeds + let(:user) { User.create!(username: 'testuser', email: 'test@example.com') } + + let(:party) do + Party.create!( + user: user, + name: 'Test Party', + raid_id: nil, + element: 0, + visibility: 'public' + ) + end + + let(:access_token) do + Doorkeeper::AccessToken.create!( + resource_owner_id: user.id, + expires_in: 30.days, + scopes: 'public' + ) + end + + let(:headers) do + { + 'Authorization' => "Bearer #{access_token.token}", + 'Content-Type' => 'application/json' + } + end + + describe 'GridWeapon endpoints' do + # Create minimal weapon for testing + let(:weapon) { Weapon.create!(name_en: 'Test Weapon', element: 0, granblue_id: 'test-001') } + let!(:grid_weapon1) do + GridWeapon.create!( + party: party, + weapon: weapon, + position: 0, + uncap_level: 3, + transcendence_step: 0 + ) + end + + describe 'PUT /api/v1/parties/:party_id/grid_weapons/:id/position' do + it 'updates position when valid' do + put "/api/v1/parties/#{party.id}/grid_weapons/#{grid_weapon1.id}/position", + params: { position: 3 }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + expect(grid_weapon1.reload.position).to eq(3) + end + + it 'returns error for invalid position' do + put "/api/v1/parties/#{party.id}/grid_weapons/#{grid_weapon1.id}/position", + params: { position: 20 }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe 'POST /api/v1/parties/:party_id/grid_weapons/swap' do + let!(:grid_weapon2) do + GridWeapon.create!( + party: party, + weapon: weapon, + position: 2, + uncap_level: 3, + transcendence_step: 0 + ) + end + + it 'swaps positions of two weapons' do + post "/api/v1/parties/#{party.id}/grid_weapons/swap", + params: { source_id: grid_weapon1.id, target_id: grid_weapon2.id }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + expect(grid_weapon1.reload.position).to eq(2) + expect(grid_weapon2.reload.position).to eq(0) + end + end + end + + describe 'Batch Grid Update' do + let(:weapon) { Weapon.create!(name_en: 'Test Weapon', element: 0, granblue_id: 'test-002') } + let!(:grid_weapon) do + GridWeapon.create!( + party: party, + weapon: weapon, + position: 0, + uncap_level: 3, + transcendence_step: 0 + ) + end + + describe 'POST /api/v1/parties/:id/grid_update' do + it 'performs move operation' do + operations = [ + { type: 'move', entity: 'weapon', id: grid_weapon.id, position: 4 } + ] + + post "/api/v1/parties/#{party.id}/grid_update", + params: { operations: operations }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['operations_applied']).to eq(1) + expect(grid_weapon.reload.position).to eq(4) + end + + it 'validates operations before executing' do + operations = [ + { type: 'invalid', entity: 'weapon', id: grid_weapon.id } + ] + + post "/api/v1/parties/#{party.id}/grid_update", + params: { operations: operations }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'Authorization' do + let(:other_user) { User.create!(username: 'other', email: 'other@example.com') } + let(:other_party) { Party.create!(user: other_user, name: 'Other Party') } + let(:weapon) { Weapon.create!(name_en: 'Test Weapon', element: 0, granblue_id: 'test-003') } + let!(:other_weapon) do + GridWeapon.create!( + party: other_party, + weapon: weapon, + position: 0, + uncap_level: 3, + transcendence_step: 0 + ) + end + + it 'denies access to other users party' do + put "/api/v1/parties/#{other_party.id}/grid_weapons/#{other_weapon.id}/position", + params: { position: 3 }.to_json, + headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end +end \ No newline at end of file diff --git a/spec/requests/drag_drop_endpoints_spec.rb b/spec/requests/drag_drop_endpoints_spec.rb new file mode 100644 index 0000000..663d507 --- /dev/null +++ b/spec/requests/drag_drop_endpoints_spec.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Drag Drop API Endpoints', type: :request do + let(:user) { create(:user) } + let(:party) { create(:party, user: user) } + let(:anonymous_party) { create(:party, user: nil, edit_key: 'test-edit-key') } + + let(:access_token) do + Doorkeeper::AccessToken.create!( + resource_owner_id: user.id, + expires_in: 30.days, + scopes: 'public' + ) + end + + let(:headers) do + { + 'Authorization' => "Bearer #{access_token.token}", + 'Content-Type' => 'application/json' + } + end + + let(:anonymous_headers) do + { + 'X-Edit-Key' => 'test-edit-key', + 'Content-Type' => 'application/json' + } + end + + describe 'GridWeapon Position Updates' do + let!(:weapon1) { create(:grid_weapon, party: party, position: 0) } + let!(:weapon2) { create(:grid_weapon, party: party, position: 2) } + + describe 'PUT /api/v1/parties/:party_id/grid_weapons/:id/position' do + context 'with valid parameters' do + it 'updates weapon position to empty slot' do + put "/api/v1/parties/#{party.id}/grid_weapons/#{weapon1.id}/position", + params: { position: 4, container: 'main' }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['grid_weapon']['position']).to eq(4) + expect(weapon1.reload.position).to eq(4) + end + + it 'returns error when position is occupied' do + put "/api/v1/parties/#{party.id}/grid_weapons/#{weapon1.id}/position", + params: { position: 2 }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('occupied') + end + + it 'returns error for invalid position' do + put "/api/v1/parties/#{party.id}/grid_weapons/#{weapon1.id}/position", + params: { position: 20 }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('Invalid position') + end + end + + context 'with anonymous party' do + let!(:anon_weapon) { create(:grid_weapon, party: anonymous_party, position: 1) } + + it 'allows update with correct edit key' do + put "/api/v1/parties/#{anonymous_party.id}/grid_weapons/#{anon_weapon.id}/position", + params: { position: 3 }.to_json, + headers: anonymous_headers + + expect(response).to have_http_status(:ok) + expect(anon_weapon.reload.position).to eq(3) + end + + it 'denies update with wrong edit key' do + put "/api/v1/parties/#{anonymous_party.id}/grid_weapons/#{anon_weapon.id}/position", + params: { position: 3 }.to_json, + headers: { 'X-Edit-Key' => 'wrong-key', 'Content-Type' => 'application/json' } + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/parties/:party_id/grid_weapons/swap' do + it 'swaps two weapon positions' do + post "/api/v1/parties/#{party.id}/grid_weapons/swap", + params: { source_id: weapon1.id, target_id: weapon2.id }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['swapped']['source']['position']).to eq(2) + expect(json['swapped']['target']['position']).to eq(0) + + expect(weapon1.reload.position).to eq(2) + expect(weapon2.reload.position).to eq(0) + end + + it 'returns error when weapons not found' do + post "/api/v1/parties/#{party.id}/grid_weapons/swap", + params: { source_id: 'invalid', target_id: weapon2.id }.to_json, + headers: headers + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'GridCharacter Position Updates' do + let!(:char1) { create(:grid_character, party: party, position: 0) } + let!(:char2) { create(:grid_character, party: party, position: 1) } + let!(:char3) { create(:grid_character, party: party, position: 2) } + + describe 'PUT /api/v1/parties/:party_id/grid_characters/:id/position' do + it 'updates character position and maintains sequential filling' do + put "/api/v1/parties/#{party.id}/grid_characters/#{char1.id}/position", + params: { position: 5, container: 'extra' }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['grid_character']['position']).to eq(5) + expect(json['reordered']).to be true + + # Check compaction happened + expect(char2.reload.position).to eq(0) + expect(char3.reload.position).to eq(1) + end + + it 'returns error for invalid position' do + put "/api/v1/parties/#{party.id}/grid_characters/#{char1.id}/position", + params: { position: 7 }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('Invalid position') + end + end + + describe 'POST /api/v1/parties/:party_id/grid_characters/swap' do + it 'swaps two character positions' do + post "/api/v1/parties/#{party.id}/grid_characters/swap", + params: { source_id: char1.id, target_id: char3.id }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['swapped']['source']['position']).to eq(2) + expect(json['swapped']['target']['position']).to eq(0) + + expect(char1.reload.position).to eq(2) + expect(char3.reload.position).to eq(0) + end + end + end + + describe 'GridSummon Position Updates' do + let!(:summon1) { create(:grid_summon, party: party, position: 0) } + let!(:summon2) { create(:grid_summon, party: party, position: 2) } + + describe 'PUT /api/v1/parties/:party_id/grid_summons/:id/position' do + it 'updates summon position to empty slot' do + put "/api/v1/parties/#{party.id}/grid_summons/#{summon1.id}/position", + params: { position: 3, container: 'sub' }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['grid_summon']['position']).to eq(3) + expect(summon1.reload.position).to eq(3) + end + + it 'returns error for restricted position' do + put "/api/v1/parties/#{party.id}/grid_summons/#{summon1.id}/position", + params: { position: -1 }.to_json, # Main summon position + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('restricted position') + end + + it 'returns error for friend summon position' do + put "/api/v1/parties/#{party.id}/grid_summons/#{summon1.id}/position", + params: { position: 6 }.to_json, # Friend summon position + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('restricted position') + end + end + + describe 'POST /api/v1/parties/:party_id/grid_summons/swap' do + it 'swaps two summon positions' do + post "/api/v1/parties/#{party.id}/grid_summons/swap", + params: { source_id: summon1.id, target_id: summon2.id }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['swapped']['source']['position']).to eq(2) + expect(json['swapped']['target']['position']).to eq(0) + + expect(summon1.reload.position).to eq(2) + expect(summon2.reload.position).to eq(0) + end + + it 'returns error when trying to swap with restricted position' do + restricted_summon = create(:grid_summon, party: party, position: -1) # Main summon + + post "/api/v1/parties/#{party.id}/grid_summons/swap", + params: { source_id: summon1.id, target_id: restricted_summon.id }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('restricted') + end + end + end + + describe 'Batch Grid Update' do + let!(:weapon) { create(:grid_weapon, party: party, position: 0) } + let!(:char1) { create(:grid_character, party: party, position: 0) } + let!(:char2) { create(:grid_character, party: party, position: 1) } + let!(:summon) { create(:grid_summon, party: party, position: 1) } + + describe 'POST /api/v1/parties/:id/grid_update' do + it 'performs multiple operations atomically' do + operations = [ + { type: 'move', entity: 'weapon', id: weapon.id, position: 3 }, + { type: 'swap', entity: 'character', source_id: char1.id, target_id: char2.id }, + { type: 'remove', entity: 'summon', id: summon.id } + ] + + post "/api/v1/parties/#{party.id}/grid_update", + params: { operations: operations }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json['operations_applied']).to eq(3) + expect(json['changes'].count).to eq(3) + + # Verify operations + expect(weapon.reload.position).to eq(3) + expect(char1.reload.position).to eq(1) + expect(char2.reload.position).to eq(0) + expect { summon.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'validates all operations before executing' do + operations = [ + { type: 'move', entity: 'weapon', id: weapon.id, position: 3 }, + { type: 'invalid', entity: 'character', id: char1.id } # Invalid operation + ] + + post "/api/v1/parties/#{party.id}/grid_update", + params: { operations: operations }.to_json, + headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('unknown operation type') + + # Ensure no operations were applied + expect(weapon.reload.position).to eq(0) + end + + it 'maintains character sequence when option is set' do + char3 = create(:grid_character, party: party, position: 2) + + operations = [ + { type: 'move', entity: 'character', id: char2.id, position: 5 } # Move to extra + ] + + post "/api/v1/parties/#{party.id}/grid_update", + params: { + operations: operations, + options: { maintain_character_sequence: true } + }.to_json, + headers: headers + + expect(response).to have_http_status(:ok) + + # Check compaction happened + expect(char1.reload.position).to eq(0) + expect(char3.reload.position).to eq(1) + expect(char2.reload.position).to eq(5) + end + + it 'rolls back all operations on error' do + operations = [ + { type: 'move', entity: 'weapon', id: weapon.id, position: 3 }, + { type: 'move', entity: 'weapon', id: 'invalid-id', position: 4 } + ] + + expect { + post "/api/v1/parties/#{party.id}/grid_update", + params: { operations: operations }.to_json, + headers: headers + }.not_to change { weapon.reload.position } + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'Authorization' do + let(:other_user) { create(:user) } + let(:other_party) { create(:party, user: other_user) } + let!(:weapon) { create(:grid_weapon, party: other_party, position: 0) } + + it 'denies access to other users party' do + put "/api/v1/parties/#{other_party.id}/grid_weapons/#{weapon.id}/position", + params: { position: 3 }.to_json, + headers: headers + + expect(response).to have_http_status(:unauthorized) + end + + it 'denies swap on other users party' do + weapon2 = create(:grid_weapon, party: other_party, position: 1) + + post "/api/v1/parties/#{other_party.id}/grid_weapons/swap", + params: { source_id: weapon.id, target_id: weapon2.id }.to_json, + headers: headers + + expect(response).to have_http_status(:unauthorized) + end + + it 'denies batch update on other users party' do + post "/api/v1/parties/#{other_party.id}/grid_update", + params: { operations: [] }.to_json, + headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end +end \ No newline at end of file