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:
parent
819a61015f
commit
07e5488e0b
14 changed files with 1670 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
7
db/migrate/20250307080232_drop_game_raw_en_backup.rb
Normal file
7
db/migrate/20250307080232_drop_game_raw_en_backup.rb
Normal 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
|
||||
166
db/schema.rb
166
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
|
||||
|
|
|
|||
140
lib/granblue/parsers/base_parser.rb
Normal file
140
lib/granblue/parsers/base_parser.rb
Normal 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
|
||||
620
lib/granblue/parsers/character_skill_parser.rb
Normal file
620
lib/granblue/parsers/character_skill_parser.rb
Normal 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
|
||||
101
lib/granblue/parsers/weapon_skill_parser.rb
Normal file
101
lib/granblue/parsers/weapon_skill_parser.rb
Normal 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
|
||||
154
spec/requests/drag_drop_api_spec.rb
Normal file
154
spec/requests/drag_drop_api_spec.rb
Normal 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
|
||||
350
spec/requests/drag_drop_endpoints_spec.rb
Normal file
350
spec/requests/drag_drop_endpoints_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue