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
This commit is contained in:
Justin Edmund 2025-09-17 05:44:14 -07:00
parent 819a61015f
commit 07e5488e0b
14 changed files with 1670 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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