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
|
##### Constants
|
||||||
COLLECTION_PER_PAGE = 15
|
COLLECTION_PER_PAGE = 15
|
||||||
SEARCH_PER_PAGE = 10
|
SEARCH_PER_PAGE = 10
|
||||||
|
MAX_PER_PAGE = 100
|
||||||
|
MIN_PER_PAGE = 1
|
||||||
|
|
||||||
##### Errors
|
##### Errors
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response
|
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response
|
||||||
|
|
@ -121,6 +123,24 @@ module Api
|
||||||
raise UnauthorizedError unless current_user
|
raise UnauthorizedError unless current_user
|
||||||
end
|
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
|
def n_plus_one_detection
|
||||||
Prosopite.scan
|
Prosopite.scan
|
||||||
yield
|
yield
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,8 @@ module Api
|
||||||
# Lists parties based on query parameters.
|
# Lists parties based on query parameters.
|
||||||
def index
|
def index
|
||||||
query = build_filtered_query(build_common_base_query)
|
query = build_filtered_query(build_common_base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: page_size)
|
||||||
render_paginated_parties(@parties)
|
render_paginated_parties(@parties, page_size)
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /api/v1/parties/favorites
|
# GET /api/v1/parties/favorites
|
||||||
|
|
@ -157,8 +157,8 @@ module Api
|
||||||
.where(favorites: { user_id: current_user.id })
|
.where(favorites: { user_id: current_user.id })
|
||||||
.distinct
|
.distinct
|
||||||
query = build_filtered_query(base_query)
|
query = build_filtered_query(base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: page_size)
|
||||||
render_paginated_parties(@parties)
|
render_paginated_parties(@parties, page_size)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Preview Management
|
# Preview Management
|
||||||
|
|
|
||||||
|
|
@ -82,14 +82,14 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = characters.length
|
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,
|
render json: CharacterBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: {
|
||||||
count: count,
|
count: count,
|
||||||
total_pages: total_pages(count),
|
total_pages: total_pages(count),
|
||||||
per_page: SEARCH_PER_PAGE
|
per_page: search_page_size
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -120,14 +120,14 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = weapons.length
|
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,
|
render json: WeaponBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: {
|
||||||
count: count,
|
count: count,
|
||||||
total_pages: total_pages(count),
|
total_pages: total_pages(count),
|
||||||
per_page: SEARCH_PER_PAGE
|
per_page: search_page_size
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -153,14 +153,14 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = summons.length
|
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,
|
render json: SummonBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: {
|
||||||
count: count,
|
count: count,
|
||||||
total_pages: total_pages(count),
|
total_pages: total_pages(count),
|
||||||
per_page: SEARCH_PER_PAGE
|
per_page: search_page_size
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -241,14 +241,14 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = skills.length
|
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,
|
render json: JobSkillBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: {
|
||||||
count: count,
|
count: count,
|
||||||
total_pages: total_pages(count),
|
total_pages: total_pages(count),
|
||||||
per_page: SEARCH_PER_PAGE
|
per_page: search_page_size
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -261,14 +261,14 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = books.length
|
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,
|
render json: GuidebookBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: {
|
||||||
count: count,
|
count: count,
|
||||||
total_pages: total_pages(count),
|
total_pages: total_pages(count),
|
||||||
per_page: SEARCH_PER_PAGE
|
per_page: search_page_size
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,13 +79,14 @@ module Api
|
||||||
current_user: current_user,
|
current_user: current_user,
|
||||||
options: { skip_privacy: skip_privacy }
|
options: { skip_privacy: skip_privacy }
|
||||||
).build
|
).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
|
count = query.count
|
||||||
render json: UserBlueprint.render(@user,
|
render json: UserBlueprint.render(@user,
|
||||||
view: :profile,
|
view: :profile,
|
||||||
root: 'profile',
|
root: 'profile',
|
||||||
parties: parties,
|
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
|
current_user: current_user
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ module PartyQueryingConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
# Renders paginated parties using PartyBlueprint.
|
# 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(
|
render json: Api::V1::PartyBlueprint.render(
|
||||||
parties,
|
parties,
|
||||||
view: :preview,
|
view: :preview,
|
||||||
|
|
@ -40,7 +40,7 @@ module PartyQueryingConcern
|
||||||
meta: {
|
meta: {
|
||||||
count: parties.total_entries,
|
count: parties.total_entries,
|
||||||
total_pages: parties.total_pages,
|
total_pages: parties.total_pages,
|
||||||
per_page: COLLECTION_PER_PAGE
|
per_page: per_page
|
||||||
},
|
},
|
||||||
current_user: current_user
|
current_user: current_user
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,24 @@
|
||||||
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
||||||
allow do
|
allow do
|
||||||
if Rails.env.production?
|
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
|
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
|
end
|
||||||
|
|
||||||
resource '*',
|
resource '*',
|
||||||
headers: :any,
|
headers: :any,
|
||||||
methods: %i[get post put patch delete options head]
|
methods: %i[get post put patch delete options head],
|
||||||
|
credentials: true
|
||||||
end
|
end
|
||||||
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "btree_gin"
|
enable_extension "btree_gin"
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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
|
t.integer "order", default: 0, null: false
|
||||||
end
|
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|
|
create_table "characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name_en"
|
t.string "name_en"
|
||||||
t.string "name_jp"
|
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_en", default: [], null: false, array: true
|
||||||
t.string "nicknames_jp", default: [], null: false, array: true
|
t.string "nicknames_jp", default: [], null: false, array: true
|
||||||
t.text "wiki_raw"
|
t.text "wiki_raw"
|
||||||
t.text "game_raw_en"
|
t.jsonb "game_raw_en", comment: "JSON data from game (English)"
|
||||||
t.text "game_raw_jp"
|
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 ["granblue_id"], name: "index_characters_on_granblue_id"
|
||||||
t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
||||||
end
|
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|
|
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||||
end
|
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
|
t.index ["filename"], name: "index_data_versions_on_filename", unique: true
|
||||||
end
|
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|
|
create_table "favorites", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "user_id"
|
t.uuid "user_id"
|
||||||
t.uuid "party_id"
|
t.uuid "party_id"
|
||||||
|
|
@ -103,6 +150,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
||||||
t.boolean "holiday"
|
t.boolean "holiday"
|
||||||
t.string "drawable_type"
|
t.string "drawable_type"
|
||||||
t.uuid "drawable_id"
|
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_id"], name: "index_gacha_on_drawable_id", unique: true
|
||||||
t.index ["drawable_type", "drawable_id"], name: "index_gacha_on_drawable"
|
t.index ["drawable_type", "drawable_id"], name: "index_gacha_on_drawable"
|
||||||
end
|
end
|
||||||
|
|
@ -375,6 +424,51 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
||||||
t.uuid "group_id"
|
t.uuid "group_id"
|
||||||
end
|
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|
|
create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "user_id", null: false
|
t.string "user_id", null: false
|
||||||
t.string "guild_ids", null: false, array: true
|
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
|
t.index ["user_id"], name: "index_sparks_on_user_id", unique: true
|
||||||
end
|
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|
|
create_table "summons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name_en"
|
t.string "name_en"
|
||||||
t.string "name_jp"
|
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_en", default: [], null: false, array: true
|
||||||
t.string "nicknames_jp", default: [], null: false, array: true
|
t.string "nicknames_jp", default: [], null: false, array: true
|
||||||
t.text "wiki_raw"
|
t.text "wiki_raw"
|
||||||
t.text "game_raw_en"
|
t.jsonb "game_raw_en", comment: "JSON data from game (English)"
|
||||||
t.text "game_raw_jp"
|
t.jsonb "game_raw_jp", comment: "JSON data from game (Japanese)"
|
||||||
t.index ["granblue_id"], name: "index_summons_on_granblue_id"
|
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
|
t.index ["name_en"], name: "index_summons_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
||||||
end
|
end
|
||||||
|
|
@ -464,6 +589,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
||||||
t.integer "series", default: [], null: false, array: true
|
t.integer "series", default: [], null: false, array: true
|
||||||
end
|
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|
|
create_table "weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name_en"
|
t.string "name_en"
|
||||||
t.string "name_jp"
|
t.string "name_jp"
|
||||||
|
|
@ -503,13 +644,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
||||||
t.integer "series"
|
t.integer "series"
|
||||||
t.integer "new_series"
|
t.integer "new_series"
|
||||||
t.text "wiki_raw"
|
t.text "wiki_raw"
|
||||||
t.text "game_raw_en"
|
t.jsonb "game_raw_en", comment: "JSON data from game (English)"
|
||||||
t.text "game_raw_jp"
|
t.jsonb "game_raw_jp", comment: "JSON data from game (Japanese)"
|
||||||
t.index ["granblue_id"], name: "index_weapons_on_granblue_id"
|
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 ["name_en"], name: "index_weapons_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
||||||
t.index ["recruits"], name: "index_weapons_on_recruits"
|
t.index ["recruits"], name: "index_weapons_on_recruits"
|
||||||
end
|
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", "parties"
|
||||||
add_foreign_key "favorites", "users"
|
add_foreign_key "favorites", "users"
|
||||||
add_foreign_key "grid_characters", "awakenings"
|
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", "raids"
|
||||||
add_foreign_key "parties", "users"
|
add_foreign_key "parties", "users"
|
||||||
add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey"
|
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", "awakenings"
|
||||||
add_foreign_key "weapon_awakenings", "weapons"
|
add_foreign_key "weapon_awakenings", "weapons"
|
||||||
|
add_foreign_key "weapon_skills", "skills"
|
||||||
end
|
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