Compare commits
37 commits
jedmund/fi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b03d5e6618 | |||
| 309a499446 | |||
| 832bf86d47 | |||
| 65a58d8b4c | |||
| 92de40bbbf | |||
| aaa046c01f | |||
| 2f04a7d3a7 | |||
| 7880ac76cc | |||
| 28a6b1894e | |||
| 3746ee9af6 | |||
| cec6132823 | |||
| 311c218863 | |||
| c060a4525b | |||
| 2b8dfe9e20 | |||
| b1800f411f | |||
| 2de10d03f3 | |||
| a6ede6ecf7 | |||
| e75578bea3 | |||
| ffbc8d0c1e | |||
| 0d997d6ad5 | |||
| 5955ef2650 | |||
| ae62d594a8 | |||
| 82b3d0ed88 | |||
| e1d983a6d4 | |||
| 4d3c1a800b | |||
| b2d2952b35 | |||
| 6bcbc97566 | |||
| 9827658771 | |||
| 505176ae5f | |||
| 6db5faeb98 | |||
| 11db6674fc | |||
| bb96593798 | |||
| 3cdd925162 | |||
| 60f153a169 | |||
| a042847aab | |||
| d6300f7aeb | |||
| 6cf11e6517 |
141 changed files with 13844 additions and 2310 deletions
26
.aidigestignore
Normal file
26
.aidigestignore
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
.*
|
||||
|
||||
app/assets
|
||||
bin/
|
||||
coverage/
|
||||
download/
|
||||
export/
|
||||
log/
|
||||
postgres/
|
||||
public/
|
||||
storage/
|
||||
tmp/
|
||||
vendor/
|
||||
lib/
|
||||
sig/
|
||||
test/
|
||||
|
||||
LICENSE
|
||||
logfile
|
||||
config.ru
|
||||
codebase.md
|
||||
Rakefile
|
||||
|
||||
db/migrate/*
|
||||
db/data/*
|
||||
db/seed/updates/*
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -21,6 +21,9 @@
|
|||
!/log/.keep
|
||||
!/tmp/.keep
|
||||
|
||||
# Ignore simplecov directory
|
||||
/coverage/*
|
||||
|
||||
# Ignore pidfiles, but keep the directory.
|
||||
/tmp/pids/*
|
||||
!/tmp/pids/
|
||||
|
|
@ -34,9 +37,12 @@
|
|||
# Ignore master key for decrypting credentials and more.
|
||||
/config/master.key
|
||||
|
||||
# Ignore exported and downloaded files
|
||||
# Ignore specific directories
|
||||
/.local
|
||||
/export
|
||||
/download
|
||||
/backups
|
||||
/logs
|
||||
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -52,3 +58,4 @@ config/application.yml
|
|||
|
||||
# Ignore AI Codebase-generated files
|
||||
codebase.md
|
||||
mise.toml
|
||||
|
|
|
|||
1
.rspec
Normal file
1
.rspec
Normal file
|
|
@ -0,0 +1 @@
|
|||
--require spec_helper
|
||||
2
.rubocop.yml
Normal file
2
.rubocop.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Layout/MultilineOperationIndentation:
|
||||
EnforcedStyle: aligned
|
||||
15
Gemfile
15
Gemfile
|
|
@ -76,6 +76,14 @@ gem 'strscan'
|
|||
# New Relic Ruby Agent
|
||||
gem 'newrelic_rpm'
|
||||
|
||||
# Parallel processing made simple and fast
|
||||
gem 'parallel'
|
||||
|
||||
# The Sentry SDK for Rails
|
||||
gem 'sentry-rails'
|
||||
gem 'sentry-ruby'
|
||||
gem 'stackprof'
|
||||
|
||||
group :doc do
|
||||
gem 'apipie-rails'
|
||||
gem 'sdoc'
|
||||
|
|
@ -84,8 +92,7 @@ end
|
|||
group :development, :test do
|
||||
gem 'amazing_print'
|
||||
gem 'dotenv-rails'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'faker'
|
||||
gem 'prosopite'
|
||||
gem 'pry'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails'
|
||||
|
|
@ -94,7 +101,6 @@ end
|
|||
group :development do
|
||||
gem 'listen'
|
||||
gem 'pg_query'
|
||||
gem 'prosopite'
|
||||
gem 'solargraph'
|
||||
gem 'spring'
|
||||
gem 'spring-commands-rspec'
|
||||
|
|
@ -109,6 +115,9 @@ group :test do
|
|||
gem 'api_matchers'
|
||||
gem 'byebug'
|
||||
gem 'database_cleaner'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'faker'
|
||||
gem 'rspec'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', require: false
|
||||
end
|
||||
|
|
|
|||
12
Gemfile.lock
12
Gemfile.lock
|
|
@ -377,6 +377,12 @@ GEM
|
|||
sdoc (2.6.1)
|
||||
rdoc (>= 5.0)
|
||||
securerandom (0.4.1)
|
||||
sentry-rails (5.22.4)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.4.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.8)
|
||||
|
|
@ -420,6 +426,7 @@ GEM
|
|||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.8.0)
|
||||
stackprof (0.2.27)
|
||||
stringio (3.1.2)
|
||||
strscan (3.1.2)
|
||||
thor (1.3.2)
|
||||
|
|
@ -475,6 +482,7 @@ DEPENDENCIES
|
|||
mini_magick
|
||||
newrelic_rpm
|
||||
oj
|
||||
parallel
|
||||
pg
|
||||
pg_query
|
||||
pg_search
|
||||
|
|
@ -485,11 +493,14 @@ DEPENDENCIES
|
|||
rails
|
||||
redis
|
||||
responders
|
||||
rspec
|
||||
rspec-rails
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rufus-scheduler
|
||||
sdoc
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
shoulda-matchers
|
||||
sidekiq
|
||||
simplecov
|
||||
|
|
@ -498,6 +509,7 @@ DEPENDENCIES
|
|||
spring-commands-rspec
|
||||
sprockets-rails
|
||||
squasher (>= 0.6.0)
|
||||
stackprof
|
||||
strscan
|
||||
will_paginate (~> 3.3)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ module Api
|
|||
|
||||
# Metadata associations
|
||||
field :favorited do |party, options|
|
||||
party.is_favorited(options[:current_user])
|
||||
party.favorited?(options[:current_user])
|
||||
end
|
||||
|
||||
# For collection views
|
||||
|
|
@ -41,7 +41,7 @@ module Api
|
|||
view :full do
|
||||
# Primary object associations
|
||||
include_view :nested_objects # Characters, Weapons, Summons
|
||||
include_view :nested_metadata # Remixes, Source party
|
||||
include_view :remix_metadata # Remixes, Source party
|
||||
include_view :job_metadata # Accessory, Skills, Guidebooks
|
||||
end
|
||||
|
||||
|
|
@ -85,11 +85,15 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
view :nested_metadata do
|
||||
view :source_party do
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :minimal,
|
||||
view: :preview,
|
||||
if: ->(_field_name, party, _options) { party.source_party_id.present? }
|
||||
end
|
||||
|
||||
view :remix_metadata do
|
||||
include_view :source_party
|
||||
|
||||
# Re-added remixes association
|
||||
association :remixes,
|
||||
|
|
@ -127,6 +131,12 @@ module Api
|
|||
fields :edit_key
|
||||
end
|
||||
|
||||
# Remixed view
|
||||
view :remixed do
|
||||
include_view :created
|
||||
include_view :source_party
|
||||
end
|
||||
|
||||
# Destroyed view
|
||||
view :destroyed do
|
||||
fields :name, :description, :created_at, :updated_at
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ module Api
|
|||
end
|
||||
|
||||
def render_unprocessable_entity_response(exception)
|
||||
render json: ErrorBlueprint.render_as_json(nil, exception: exception),
|
||||
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,73 +2,115 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# Controller handling API requests related to grid characters within a party.
|
||||
#
|
||||
# This controller provides endpoints for creating, updating, resolving conflicts,
|
||||
# updating uncap levels, and deleting grid characters. It follows the structure of
|
||||
# GridSummonsController and GridWeaponsController by using the new authorization method
|
||||
# `authorize_party_edit!` and deprecating legacy methods such as `set` in favor of
|
||||
# `find_party`, `find_grid_character`, and `find_incoming_character`.
|
||||
#
|
||||
# @see Api::V1::ApiController for shared API behavior.
|
||||
class GridCharactersController < Api::V1::ApiController
|
||||
attr_reader :party, :incoming_character, :current_characters
|
||||
|
||||
before_action :find_party, only: :create
|
||||
before_action :set, only: %i[update destroy]
|
||||
before_action :authorize, only: %i[create update destroy]
|
||||
before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve]
|
||||
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
|
||||
before_action :find_incoming_character, only: :create
|
||||
before_action :find_current_characters, only: :create
|
||||
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
|
||||
|
||||
##
|
||||
# Creates a new grid character.
|
||||
#
|
||||
# If a conflicting grid character is found (i.e. one with the same character_id already exists
|
||||
# in the party), a conflict view is rendered so the user can decide on removal. Otherwise,
|
||||
# any grid character occupying the desired position is removed and a new one is created.
|
||||
#
|
||||
# @return [void]
|
||||
def create
|
||||
if !conflict_characters.nil? && conflict_characters.length.positive?
|
||||
# Render a template with the conflicting and incoming characters,
|
||||
# as well as the selected position, so the user can be presented with
|
||||
# a decision.
|
||||
processed_params = transform_character_params(character_params)
|
||||
|
||||
# Up to 3 characters can be removed at the same time
|
||||
conflict_view = render_conflict_view(conflict_characters, incoming_character, character_params[:position])
|
||||
render json: conflict_view
|
||||
if conflict_characters.present?
|
||||
render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position])
|
||||
else
|
||||
# Destroy the grid character in the position if it is already filled
|
||||
if GridCharacter.where(party_id: party.id, position: character_params[:position]).exists?
|
||||
character = GridCharacter.where(party_id: party.id, position: character_params[:position]).limit(1)[0]
|
||||
character.destroy
|
||||
# Remove any existing grid character occupying the specified position.
|
||||
if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position]))
|
||||
existing.destroy
|
||||
end
|
||||
|
||||
# Then, create a new grid character
|
||||
character = GridCharacter.create!(character_params.merge(party_id: party.id,
|
||||
character_id: incoming_character.id))
|
||||
# Build the new grid character
|
||||
grid_character = build_new_grid_character(processed_params)
|
||||
|
||||
if character.save!
|
||||
grid_character_view = render_grid_character_view(character)
|
||||
render json: grid_character_view, status: :created
|
||||
if grid_character.save
|
||||
render json: GridCharacterBlueprint.render(grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested), status: :created
|
||||
else
|
||||
render_validation_error_response(grid_character)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates an existing grid character.
|
||||
#
|
||||
# Assigns new rings and awakening data to their respective virtual attributes and updates other
|
||||
# permitted attributes. On success, the updated grid character view is rendered.
|
||||
#
|
||||
# @return [void]
|
||||
def update
|
||||
permitted = character_params.to_h.deep_symbolize_keys
|
||||
puts "Permitted:"
|
||||
ap permitted
|
||||
processed_params = transform_character_params(character_params)
|
||||
assign_raw_attributes(@grid_character)
|
||||
assign_transformed_attributes(@grid_character, processed_params)
|
||||
|
||||
# For the new nested structure, assign them to the virtual attributes:
|
||||
@character.new_rings = permitted[:rings] if permitted[:rings].present?
|
||||
@character.new_awakening = permitted[:awakening] if permitted[:awakening].present?
|
||||
|
||||
# For the rest of the attributes, you can assign them normally.
|
||||
@character.assign_attributes(permitted.except(:rings, :awakening))
|
||||
|
||||
if @character.save
|
||||
render json: GridCharacterBlueprint.render(@character, view: :nested)
|
||||
if @grid_character.save
|
||||
render json: GridCharacterBlueprint.render(@grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested)
|
||||
else
|
||||
render_validation_error_response(@character)
|
||||
render_validation_error_response(@grid_character)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the uncap level and transcendence step of a grid character.
|
||||
#
|
||||
# The grid character's uncap level and transcendence step are updated based on the provided parameters.
|
||||
# This action requires that the current user is authorized to modify the party.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
@grid_character.uncap_level = character_params[:uncap_level]
|
||||
@grid_character.transcendence_step = character_params[:transcendence_step]
|
||||
|
||||
if @grid_character.save
|
||||
render json: GridCharacterBlueprint.render(@grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested)
|
||||
else
|
||||
render_validation_error_response(@grid_character)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Resolves conflicts for grid characters.
|
||||
#
|
||||
# This action destroys any conflicting grid characters as well as any grid character occupying
|
||||
# the target position, then creates a new grid character using a computed default uncap level.
|
||||
# The default uncap level is determined by the incoming character's attributes.
|
||||
#
|
||||
# @return [void]
|
||||
def resolve
|
||||
incoming = Character.find(resolve_params[:incoming])
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) }
|
||||
party = conflicting.first.party
|
||||
incoming = Character.find_by(id: resolve_params[:incoming])
|
||||
render_not_found_response('character') and return unless incoming
|
||||
|
||||
# Destroy each conflicting character
|
||||
conflicting.each { |character| GridCharacter.destroy(character.id) }
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
|
||||
conflicting.each(&:destroy)
|
||||
|
||||
# Destroy the character at the desired position if it exists
|
||||
existing_character = GridCharacter.where(party: party.id, position: resolve_params[:position]).first
|
||||
GridCharacter.destroy(existing_character.id) if existing_character
|
||||
if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||
existing.destroy
|
||||
end
|
||||
|
||||
# Compute the default uncap level based on the incoming character's flags.
|
||||
if incoming.special
|
||||
uncap_level = 3
|
||||
uncap_level = 5 if incoming.ulb
|
||||
|
|
@ -79,33 +121,146 @@ module Api
|
|||
uncap_level = 5 if incoming.flb
|
||||
end
|
||||
|
||||
character = GridCharacter.create!(party_id: party.id, character_id: incoming.id,
|
||||
position: resolve_params[:position], uncap_level: uncap_level)
|
||||
render json: GridCharacterBlueprint.render(character, view: :nested), status: :created if character.save!
|
||||
grid_character = GridCharacter.create!(
|
||||
party_id: @party.id,
|
||||
character_id: incoming.id,
|
||||
position: resolve_params[:position],
|
||||
uncap_level: uncap_level
|
||||
)
|
||||
render json: GridCharacterBlueprint.render(grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested), status: :created
|
||||
end
|
||||
|
||||
def update_uncap_level
|
||||
character = GridCharacter.find(character_params[:id])
|
||||
|
||||
render_unauthorized_response if current_user && (character.party.user != current_user)
|
||||
|
||||
character.uncap_level = character_params[:uncap_level]
|
||||
character.transcendence_step = character_params[:transcendence_step]
|
||||
return unless character.save!
|
||||
|
||||
render json: GridCharacterBlueprint.render(character, view: :nested, root: :grid_character)
|
||||
end
|
||||
|
||||
# TODO: Implement removing characters
|
||||
##
|
||||
# Destroys a grid character.
|
||||
#
|
||||
# If the current user is not the owner of the party, an unauthorized response is rendered.
|
||||
# On successful destruction, the destroyed grid character view is rendered.
|
||||
#
|
||||
# @return [void]
|
||||
def destroy
|
||||
render_unauthorized_response if @character.party.user != current_user
|
||||
return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @character.destroy
|
||||
grid_character = GridCharacter.find_by('id = ?', params[:id])
|
||||
|
||||
return render_not_found_response('grid_character') if grid_character.nil?
|
||||
|
||||
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Builds a new grid character using the transformed parameters.
|
||||
#
|
||||
# @param processed_params [Hash] the transformed parameters.
|
||||
# @return [GridCharacter] the newly built grid character.
|
||||
def build_new_grid_character(processed_params)
|
||||
grid_character = GridCharacter.new(
|
||||
character_params.except(:rings, :awakening).merge(
|
||||
party_id: @party.id,
|
||||
character_id: @incoming_character.id
|
||||
)
|
||||
)
|
||||
assign_transformed_attributes(grid_character, processed_params)
|
||||
assign_raw_attributes(grid_character)
|
||||
grid_character
|
||||
end
|
||||
|
||||
##
|
||||
# Assigns raw attributes from the original parameters to the grid character.
|
||||
#
|
||||
# These attributes (like new_rings and new_awakening) are used by model callbacks.
|
||||
#
|
||||
# @param grid_character [GridCharacter] the grid character instance.
|
||||
# @return [void]
|
||||
def assign_raw_attributes(grid_character)
|
||||
grid_character.new_rings = character_params[:rings] if character_params[:rings].present?
|
||||
grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present?
|
||||
grid_character.assign_attributes(character_params.except(:rings, :awakening))
|
||||
end
|
||||
|
||||
##
|
||||
# Assigns transformed attributes (such as uncap_level, transcendence_step, etc.) to the grid character.
|
||||
#
|
||||
# @param grid_character [GridCharacter] the grid character instance.
|
||||
# @param processed_params [Hash] the transformed parameters.
|
||||
# @return [void]
|
||||
def assign_transformed_attributes(grid_character, processed_params)
|
||||
grid_character.uncap_level = processed_params[:uncap_level] if processed_params[:uncap_level]
|
||||
grid_character.transcendence_step = processed_params[:transcendence_step] if processed_params[:transcendence_step]
|
||||
grid_character.perpetuity = processed_params[:perpetuity] if processed_params.key?(:perpetuity)
|
||||
grid_character.earring = processed_params[:earring] if processed_params[:earring]
|
||||
|
||||
return unless processed_params[:awakening_id]
|
||||
|
||||
grid_character.awakening_id = processed_params[:awakening_id]
|
||||
grid_character.awakening_level = processed_params[:awakening_level]
|
||||
end
|
||||
|
||||
##
|
||||
# Transforms the incoming character parameters to the required format.
|
||||
#
|
||||
# The frontend sends parameters in a raw format that need to be processed (e.g., converting string
|
||||
# values to integers, handling nested attributes for rings and awakening). This method extracts and
|
||||
# converts only the keys that were provided.
|
||||
#
|
||||
# @param raw_params [ActionController::Parameters] the raw permitted parameters.
|
||||
# @return [Hash] the transformed parameters.
|
||||
def transform_character_params(raw_params)
|
||||
# Convert to a symbolized hash for convenience.
|
||||
raw = raw_params.to_h.deep_symbolize_keys
|
||||
|
||||
# Only update keys that were provided.
|
||||
transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity)
|
||||
transformed[:uncap_level] = raw[:uncap_level] if raw[:uncap_level].present?
|
||||
transformed[:transcendence_step] = raw[:transcendence_step] if raw[:transcendence_step].present?
|
||||
|
||||
# Process rings if provided.
|
||||
transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present?
|
||||
|
||||
# Process earring if provided.
|
||||
transformed[:earring] = raw[:earring] if raw[:earring].present?
|
||||
|
||||
# Process awakening if provided.
|
||||
if raw[:awakening].present?
|
||||
transformed[:awakening_id] = raw[:awakening][:id]
|
||||
# Default to 1 if level is missing (to satisfy validations)
|
||||
transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level] : 1
|
||||
end
|
||||
|
||||
transformed
|
||||
end
|
||||
|
||||
##
|
||||
# Transforms the rings data to ensure exactly four rings are present.
|
||||
#
|
||||
# Pads the array with a default ring hash if necessary.
|
||||
#
|
||||
# @param rings [Array, Hash] the rings data from the frontend.
|
||||
# @return [Hash] a hash with keys :ring1, :ring2, :ring3, :ring4.
|
||||
def transform_rings(rings)
|
||||
default_ring = { modifier: nil, strength: nil }
|
||||
# Ensure rings is an array of hashes.
|
||||
rings_array = Array(rings).map(&:to_h)
|
||||
# Pad the array to exactly four rings if needed.
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
{
|
||||
ring1: rings_array[0],
|
||||
ring2: rings_array[1],
|
||||
ring3: rings_array[2],
|
||||
ring4: rings_array[3]
|
||||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns any grid characters in the party that conflict with the incoming character.
|
||||
#
|
||||
# Conflict is defined as any grid character already in the party with the same character_id as the
|
||||
# incoming character. This method is used to prompt the user for conflict resolution.
|
||||
#
|
||||
# @return [Array<GridCharacter>]
|
||||
def conflict_characters
|
||||
@conflict_characters ||= find_conflict_characters(incoming_character)
|
||||
@party.characters.where(character_id: @incoming_character.id).to_a
|
||||
end
|
||||
|
||||
def find_conflict_characters(incoming_character)
|
||||
|
|
@ -127,67 +282,102 @@ module Api
|
|||
end.flatten
|
||||
end
|
||||
|
||||
def set
|
||||
@character = GridCharacter.includes(:awakening).find(params[:id])
|
||||
end
|
||||
|
||||
def find_incoming_character
|
||||
@incoming_character = Character.find(character_params[:character_id])
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the party based on parameters.
|
||||
#
|
||||
# Checks for the party id in params[:character][:party_id], params[:party_id], or falls back to the party
|
||||
# associated with the current grid character. Renders a not found response if the party is missing.
|
||||
#
|
||||
# @return [void]
|
||||
def find_party
|
||||
@party = Party.find(character_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
@party = Party.find_by(id: params.dig(:character, :party_id)) ||
|
||||
Party.find_by(id: params[:party_id]) ||
|
||||
@grid_character&.party
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
def authorize
|
||||
# Create
|
||||
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
|
||||
unauthorized_update = @character && @character.party && (@character.party.user != current_user || @character.party.edit_key != edit_key)
|
||||
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
##
|
||||
# Finds and sets the grid character based on the provided parameters.
|
||||
#
|
||||
# Searches for a grid character by its ID and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_grid_character
|
||||
grid_character_id = params[:id] || params.dig(:character, :id) || params.dig(:resolve, :conflicting)
|
||||
@grid_character = GridCharacter.includes(:awakening).find_by(id: grid_character_id)
|
||||
render_not_found_response('grid_character') unless @grid_character
|
||||
end
|
||||
|
||||
def transform_character_params(raw_params)
|
||||
# Convert to a symbolized hash for convenience.
|
||||
raw = raw_params.deep_symbolize_keys
|
||||
##
|
||||
# Finds and sets the incoming character based on the provided parameters.
|
||||
#
|
||||
# Searches for a character using the :character_id parameter and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_incoming_character
|
||||
@incoming_character = Character.find_by(id: character_params[:character_id])
|
||||
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character
|
||||
end
|
||||
|
||||
# Only update keys that were provided.
|
||||
transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity)
|
||||
transformed[:uncap_level] = raw[:uncap_level].to_i if raw[:uncap_level].present?
|
||||
transformed[:transcendence_step] = raw[:transcendence_step].to_i if raw[:transcendence_step].present?
|
||||
|
||||
# Process rings if provided.
|
||||
transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present?
|
||||
|
||||
# Process earring if provided.
|
||||
transformed[:earring] = raw[:earring] if raw[:earring].present?
|
||||
|
||||
# Process awakening if provided.
|
||||
if raw[:awakening].present?
|
||||
transformed[:awakening_id] = raw[:awakening][:id]
|
||||
# Default to 1 if level is missing (to satisfy validations)
|
||||
transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level].to_i : 1
|
||||
##
|
||||
# Authorizes the current action by ensuring that the current user or provided edit key
|
||||
# matches the party's owner.
|
||||
#
|
||||
# For parties associated with a user, it verifies that the current user is the owner.
|
||||
# For anonymous parties, it compares the provided edit key with the party's edit key.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_party_edit!
|
||||
if @party.user.present?
|
||||
authorize_user_party
|
||||
else
|
||||
authorize_anonymous_party
|
||||
end
|
||||
|
||||
transformed
|
||||
end
|
||||
|
||||
def transform_rings(rings)
|
||||
default_ring = { modifier: nil, strength: nil }
|
||||
# Ensure rings is an array of hashes.
|
||||
rings_array = Array(rings).map(&:to_h)
|
||||
# Pad the array to exactly four rings if needed.
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
{
|
||||
ring1: rings_array[0],
|
||||
ring2: rings_array[1],
|
||||
ring3: rings_array[2],
|
||||
ring4: rings_array[3]
|
||||
}
|
||||
##
|
||||
# Authorizes an action for a party that belongs to a user.
|
||||
#
|
||||
# Renders an unauthorized response unless the current user is present and matches the party's user.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_user_party
|
||||
return if current_user.present? && @party.user == current_user
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
##
|
||||
# Authorizes an action for an anonymous party using an edit key.
|
||||
#
|
||||
# Compares the provided edit key with the party's edit key and renders an unauthorized response
|
||||
# if they do not match.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_anonymous_party
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @param provided_edit_key [String] the edit key provided in the request.
|
||||
# @param party_edit_key [String] the edit key associated with the party.
|
||||
# @return [Boolean] true if the keys match; false otherwise.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the allowed character parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
def character_params
|
||||
params.require(:character).permit(
|
||||
:id,
|
||||
|
|
@ -203,21 +393,13 @@ module Api
|
|||
)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the allowed resolve parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
def resolve_params
|
||||
params.require(:resolve).permit(:position, :incoming, conflicting: [])
|
||||
end
|
||||
|
||||
def render_conflict_view(conflict_characters, incoming_character, incoming_position)
|
||||
ConflictBlueprint.render(nil,
|
||||
view: :characters,
|
||||
conflict_characters: conflict_characters,
|
||||
incoming_character: incoming_character,
|
||||
incoming_position: incoming_position)
|
||||
end
|
||||
|
||||
def render_grid_character_view(grid_character)
|
||||
GridCharacterBlueprint.render(grid_character, view: :nested)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,82 +2,142 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# Controller handling API requests related to grid summons within a party.
|
||||
#
|
||||
# This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid summons.
|
||||
# It ensures that the correct party and summons are found and that the current user (or edit key) is authorized.
|
||||
#
|
||||
# @see Api::V1::ApiController for shared API behavior.
|
||||
class GridSummonsController < Api::V1::ApiController
|
||||
attr_reader :party, :incoming_summon
|
||||
|
||||
before_action :set, only: %w[update update_uncap_level update_quick_summon]
|
||||
before_action :find_party, only: :create
|
||||
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy]
|
||||
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy]
|
||||
before_action :find_incoming_summon, only: :create
|
||||
before_action :authorize, only: %i[create update update_uncap_level update_quick_summon destroy]
|
||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
|
||||
|
||||
##
|
||||
# Creates a new grid summon.
|
||||
#
|
||||
# This method builds a new grid summon using the permitted parameters merged
|
||||
# with the party and summon IDs. It ensures that the `uncap_level` is set to the
|
||||
# maximum allowed level if not provided. Depending on validation, it will either save
|
||||
# the summon, handle conflict resolution, or render a validation error response.
|
||||
#
|
||||
# @return [void]
|
||||
def create
|
||||
# Create the GridSummon with the desired parameters
|
||||
summon = GridSummon.new
|
||||
summon.attributes = summon_params.merge(party_id: party.id, summon_id: incoming_summon.id)
|
||||
summon.uncap_level = max_uncap_level(summon) if summon.uncap_level.nil?
|
||||
# Build a new grid summon using permitted parameters merged with party and summon IDs.
|
||||
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
|
||||
# if it hasn't already been provided.
|
||||
grid_summon = build_grid_summon.tap do |gs|
|
||||
gs.uncap_level ||= max_uncap_level(gs)
|
||||
end
|
||||
|
||||
if summon.validate
|
||||
save_summon(summon)
|
||||
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
||||
if grid_summon.valid?
|
||||
save_summon(grid_summon)
|
||||
# If it is invalid due to a conflict error, handle the conflict resolution flow.
|
||||
elsif conflict_error?(grid_summon)
|
||||
handle_conflict(grid_summon)
|
||||
# If there's some other kind of validation error, render the validation error response back to the client.
|
||||
else
|
||||
handle_conflict(summon)
|
||||
render_validation_error_response(grid_summon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates an existing grid summon.
|
||||
#
|
||||
# Updates the grid summon attributes using permitted parameters. If the update is successful,
|
||||
# it renders the updated grid summon view; otherwise, it renders a validation error response.
|
||||
#
|
||||
# @return [void]
|
||||
def update
|
||||
@summon.attributes = summon_params
|
||||
@grid_summon.attributes = summon_params
|
||||
|
||||
return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save
|
||||
return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save
|
||||
|
||||
render_validation_error_response(@character)
|
||||
render_validation_error_response(@grid_summon)
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the uncap level and transcendence step of a grid summon.
|
||||
#
|
||||
# This action recalculates the maximum allowed uncap level based on the summon attributes
|
||||
# and applies business logic to adjust the uncap level and transcendence step accordingly.
|
||||
# On success, it renders the updated grid summon view; otherwise, it renders a validation error response.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
summon = @summon.summon
|
||||
max_uncap_level = max_uncap_level(summon)
|
||||
summon = @grid_summon.summon
|
||||
max_level = max_uncap_level(summon)
|
||||
|
||||
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_uncap_level
|
||||
can_be_transcended = summon.transcendence && summon_params[:transcendence_step] && summon_params[:transcendence_step]&.to_i&.positive?
|
||||
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_level
|
||||
can_be_transcended = summon.transcendence &&
|
||||
summon_params[:transcendence_step].present? &&
|
||||
summon_params[:transcendence_step].to_i.positive?
|
||||
|
||||
uncap_level = if greater_than_max_uncap || can_be_transcended
|
||||
max_uncap_level
|
||||
else
|
||||
summon_params[:uncap_level]
|
||||
end
|
||||
new_uncap_level = greater_than_max_uncap || can_be_transcended ? max_level : summon_params[:uncap_level]
|
||||
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
|
||||
|
||||
transcendence_step = if summon.transcendence && summon_params[:transcendence_step]
|
||||
summon_params[:transcendence_step]
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
@summon.update!(
|
||||
uncap_level: uncap_level,
|
||||
transcendence_step: transcendence_step
|
||||
)
|
||||
|
||||
return unless @summon.persisted?
|
||||
|
||||
render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon)
|
||||
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
|
||||
render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon)
|
||||
else
|
||||
render_validation_error_response(@grid_summon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the quick summon status for a grid summon.
|
||||
#
|
||||
# If the grid summon is in positions 4, 5, or 6, no update is performed.
|
||||
# Otherwise, it disables quick summon for all other summons in the party,
|
||||
# updates the current summon, and renders the updated list of summons.
|
||||
#
|
||||
# @return [void]
|
||||
def update_quick_summon
|
||||
return if [4, 5, 6].include?(@summon.position)
|
||||
return if [4, 5, 6].include?(@grid_summon.position)
|
||||
|
||||
quick_summons = @summon.party.summons.select(&:quick_summon)
|
||||
quick_summons = @grid_summon.party.summons.select(&:quick_summon)
|
||||
|
||||
quick_summons.each do |summon|
|
||||
summon.update!(quick_summon: false)
|
||||
end
|
||||
|
||||
@summon.update!(quick_summon: summon_params[:quick_summon])
|
||||
return unless @summon.persisted?
|
||||
@grid_summon.update!(quick_summon: summon_params[:quick_summon])
|
||||
return unless @grid_summon.persisted?
|
||||
|
||||
quick_summons -= [@summon]
|
||||
summons = [@summon] + quick_summons
|
||||
quick_summons -= [@grid_summon]
|
||||
summons = [@grid_summon] + quick_summons
|
||||
|
||||
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
|
||||
end
|
||||
|
||||
#
|
||||
# Destroys a grid summon.
|
||||
#
|
||||
# Finds the grid summon by ID. If not found, renders a not-found response.
|
||||
# If the current user is not authorized to perform the deletion, renders an unauthorized response.
|
||||
# On successful destruction, renders the destroyed grid summon view.
|
||||
#
|
||||
# @return [void]
|
||||
def destroy
|
||||
grid_summon = GridSummon.find_by('id = ?', params[:id])
|
||||
|
||||
return render_not_found_response('grid_summon') if grid_summon.nil?
|
||||
|
||||
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy
|
||||
end
|
||||
|
||||
##
|
||||
# Saves the provided grid summon.
|
||||
#
|
||||
# If an existing grid summon is found at the specified position for the party, it is replaced.
|
||||
# On successful save, renders the grid summon view with a created status.
|
||||
#
|
||||
# @param summon [GridSummon] The grid summon instance to be saved.
|
||||
# @return [void]
|
||||
def save_summon(summon)
|
||||
if (grid_summon = GridSummon.where(
|
||||
party_id: party.id,
|
||||
|
|
@ -92,6 +152,15 @@ module Api
|
|||
render json: output, status: :created
|
||||
end
|
||||
|
||||
##
|
||||
# Handles conflict resolution for a grid summon.
|
||||
#
|
||||
# If a conflict is detected and the conflicting summon matches the incoming summon,
|
||||
# the method updates the conflicting summon’s position with the new position.
|
||||
# On a successful update, renders the updated grid summon view.
|
||||
#
|
||||
# @param summon [GridSummon] The grid summon instance that encountered a conflict.
|
||||
# @return [void]
|
||||
def handle_conflict(summon)
|
||||
conflict_summon = summon.conflicts(party)
|
||||
return unless conflict_summon.summon.id == incoming_summon.id
|
||||
|
|
@ -105,14 +174,96 @@ module Api
|
|||
render json: output
|
||||
end
|
||||
|
||||
def destroy
|
||||
summon = GridSummon.find_by('id = ?', params[:id])
|
||||
render_unauthorized_response if summon.party.user != current_user
|
||||
return render json: GridSummonBlueprint.render(summon, view: :destroyed) if summon.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Finds the party based on the provided party_id parameter.
|
||||
#
|
||||
# Sets the @party instance variable and renders an unauthorized response if the current
|
||||
# user is not the owner of the party.
|
||||
#
|
||||
# @return [void]
|
||||
|
||||
##
|
||||
# Finds and sets the party based on parameters.
|
||||
#
|
||||
# Renders an unauthorized response if the current user is not the owner.
|
||||
#
|
||||
# @return [void]
|
||||
def find_party
|
||||
@party = Party.find_by(id: params.dig(:summon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_summon&.party
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the GridSummon based on the provided parameters.
|
||||
#
|
||||
# Searches for a grid summon using various parameter keys and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_grid_summon
|
||||
grid_summon_id = params[:id] || params.dig(:summon, :id) || params.dig(:resolve, :conflicting)
|
||||
@grid_summon = GridSummon.find_by(id: grid_summon_id)
|
||||
render_not_found_response('grid_summon') unless @grid_summon
|
||||
end
|
||||
|
||||
##
|
||||
# Finds the incoming summon based on the provided parameters.
|
||||
#
|
||||
# Sets the @incoming_summon instance variable.
|
||||
#
|
||||
# @return [void]
|
||||
def find_incoming_summon
|
||||
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
|
||||
end
|
||||
|
||||
##
|
||||
# Builds a new GridSummon instance using permitted parameters.
|
||||
#
|
||||
# Merges the party id and the incoming summon id into the parameters.
|
||||
#
|
||||
# @return [GridSummon] A new grid summon instance.
|
||||
def build_grid_summon
|
||||
GridSummon.new(summon_params.merge(party_id: party.id, summon_id: incoming_summon.id))
|
||||
end
|
||||
|
||||
##
|
||||
# Checks whether the grid summon error is solely due to a conflict.
|
||||
#
|
||||
# Verifies if the errors on the :series attribute include the specific conflict message
|
||||
# and confirms that a conflict exists for the current party.
|
||||
#
|
||||
# @param grid_summon [GridSummon] The grid summon instance to check.
|
||||
# @return [Boolean] True if the error is due solely to a conflict, false otherwise.
|
||||
def conflict_error?(grid_summon)
|
||||
grid_summon.errors[:series].include?('must not conflict with existing summons') &&
|
||||
grid_summon.conflicts(party).present?
|
||||
end
|
||||
|
||||
##
|
||||
# Renders the grid summon view with additional metadata.
|
||||
#
|
||||
# @param grid_summon [GridSummon] The grid summon instance to render.
|
||||
# @param conflict_position [Integer, nil] The position of a conflicting summon, if applicable.
|
||||
# @return [String] The rendered grid summon view as JSON.
|
||||
def render_grid_summon_view(grid_summon, conflict_position = nil)
|
||||
GridSummonBlueprint.render(grid_summon,
|
||||
view: :nested,
|
||||
root: :grid_summon,
|
||||
meta: { replaced: conflict_position })
|
||||
end
|
||||
|
||||
##
|
||||
# Determines the maximum uncap level for a given summon.
|
||||
#
|
||||
# The maximum uncap level is determined based on the attributes of the summon:
|
||||
# - Returns 4 if the summon has FLB but not ULB and is not transcended.
|
||||
# - Returns 5 if the summon has ULB and is not transcended.
|
||||
# - Returns 6 if the summon has transcendence.
|
||||
# - Otherwise, returns 3.
|
||||
#
|
||||
# @param summon [Summon] The summon for which to determine the maximum uncap level.
|
||||
# @return [Integer] The maximum uncap level.
|
||||
def max_uncap_level(summon)
|
||||
if summon.flb && !summon.ulb && !summon.transcendence
|
||||
4
|
||||
|
|
@ -125,35 +276,65 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
def find_incoming_summon
|
||||
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
|
||||
##
|
||||
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
|
||||
#
|
||||
# For parties associated with a user, it verifies that the current_user is the owner.
|
||||
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_party_edit!
|
||||
if @party.user.present?
|
||||
authorize_user_party
|
||||
else
|
||||
authorize_anonymous_party
|
||||
end
|
||||
end
|
||||
|
||||
def find_party
|
||||
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
|
||||
@party = Party.find(summon_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
##
|
||||
# Authorizes an action for a party that belongs to a user.
|
||||
#
|
||||
# Renders an unauthorized response unless the current user is present and
|
||||
# matches the party's user.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_user_party
|
||||
return if current_user.present? && @party.user == current_user
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
def render_grid_summon_view(grid_summon, conflict_position = nil)
|
||||
GridSummonBlueprint.render(grid_summon, view: :nested,
|
||||
root: :grid_summon,
|
||||
meta: { replaced: conflict_position })
|
||||
##
|
||||
# Authorizes an action for an anonymous party using an edit key.
|
||||
#
|
||||
# Retrieves and normalizes the provided edit key and compares it with the party's edit key.
|
||||
# Renders an unauthorized response unless the keys are valid.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_anonymous_party
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
def authorize
|
||||
# Create
|
||||
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
|
||||
unauthorized_update = @summon && @summon.party && (@summon.party.user != current_user || @summon.party.edit_key != edit_key)
|
||||
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
##
|
||||
# Validates that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @param provided_edit_key [String] the edit key provided in the request.
|
||||
# @param party_edit_key [String] the edit key associated with the party.
|
||||
# @return [Boolean] true if the edit keys match; false otherwise.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
|
||||
def set
|
||||
@summon = GridSummon.find_by('id = ?', summon_params[:id])
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
##
|
||||
# Defines and permits the whitelisted parameters for a grid summon.
|
||||
#
|
||||
# @return [ActionController::Parameters] The permitted parameters.
|
||||
def summon_params
|
||||
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend,
|
||||
:quick_summon, :uncap_level, :transcendence_step)
|
||||
|
|
|
|||
|
|
@ -2,110 +2,143 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# Controller handling API requests related to grid weapons within a party.
|
||||
#
|
||||
# This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid weapons.
|
||||
# It ensures that the correct party and weapon are found and that the current user (or edit key) is authorized.
|
||||
#
|
||||
# @see Api::V1::ApiController for shared API behavior.
|
||||
class GridWeaponsController < Api::V1::ApiController
|
||||
attr_reader :party, :incoming_weapon
|
||||
|
||||
before_action :set, except: %w[create update_uncap_level]
|
||||
before_action :find_party, only: :create
|
||||
before_action :find_incoming_weapon, only: :create
|
||||
before_action :authorize, only: %i[create update destroy]
|
||||
before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy]
|
||||
before_action :find_party, only: %i[create update update_uncap_level resolve destroy]
|
||||
before_action :find_incoming_weapon, only: %i[create resolve]
|
||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
|
||||
|
||||
##
|
||||
# Creates a new GridWeapon.
|
||||
#
|
||||
# Builds a new GridWeapon using parameters merged with the party and weapon IDs.
|
||||
# If the model validations (including compatibility and conflict validations)
|
||||
# pass, the weapon is saved; otherwise, conflict resolution is attempted.
|
||||
#
|
||||
# @return [void]
|
||||
def create
|
||||
# Create the GridWeapon with the desired parameters
|
||||
weapon = GridWeapon.new
|
||||
weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id)
|
||||
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
|
||||
|
||||
if weapon.validate
|
||||
save_weapon(weapon)
|
||||
grid_weapon = GridWeapon.new(
|
||||
weapon_params.merge(
|
||||
party_id: @party.id,
|
||||
weapon_id: @incoming_weapon.id
|
||||
)
|
||||
)
|
||||
|
||||
if grid_weapon.valid?
|
||||
save_weapon(grid_weapon)
|
||||
else
|
||||
handle_conflict(weapon)
|
||||
if grid_weapon.errors[:series].include?('must not conflict with existing weapons')
|
||||
handle_conflict(grid_weapon)
|
||||
else
|
||||
render_validation_error_response(grid_weapon)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates an existing GridWeapon.
|
||||
#
|
||||
# After checking authorization, assigns new attributes to the weapon.
|
||||
# Also normalizes modifier and strength fields, then renders the updated view on success.
|
||||
#
|
||||
# @return [void]
|
||||
def update
|
||||
normalize_ax_fields!
|
||||
if @grid_weapon.update(weapon_params)
|
||||
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
|
||||
else
|
||||
render_validation_error_response(@grid_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the uncap level and transcendence step of a GridWeapon.
|
||||
#
|
||||
# Finds the weapon to update, computes the maximum allowed uncap level based on its associated
|
||||
# weapon’s flags, and then updates the fields accordingly.
|
||||
#
|
||||
# @return [void]
|
||||
def update_uncap_level
|
||||
max_uncap = compute_max_uncap_level(@grid_weapon.weapon)
|
||||
requested_uncap = weapon_params[:uncap_level].to_i
|
||||
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
|
||||
|
||||
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i)
|
||||
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
|
||||
else
|
||||
render_validation_error_response(@grid_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Resolves conflicts by removing conflicting grid weapons and creating a new one.
|
||||
#
|
||||
# Expects resolve parameters that include the desired position, the incoming weapon ID,
|
||||
# and a list of conflicting GridWeapon IDs. After deleting conflicting records and any existing
|
||||
# grid weapon at that position, creates a new GridWeapon with computed uncap_level.
|
||||
#
|
||||
# @return [void]
|
||||
def resolve
|
||||
incoming = Weapon.find(resolve_params[:incoming])
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) }
|
||||
party = conflicting.first.party
|
||||
incoming = Weapon.find_by(id: resolve_params[:incoming])
|
||||
conflicting_ids = resolve_params[:conflicting]
|
||||
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
|
||||
|
||||
# Destroy each conflicting weapon
|
||||
conflicting.each { |weapon| GridWeapon.destroy(weapon.id) }
|
||||
conflicting_weapons.each(&:destroy)
|
||||
|
||||
# Destroy the weapon at the desired position if it exists
|
||||
existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first
|
||||
GridWeapon.destroy(existing_weapon.id) if existing_weapon
|
||||
if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||
existing_weapon.destroy
|
||||
end
|
||||
|
||||
uncap_level = 3
|
||||
uncap_level = 4 if incoming.flb
|
||||
uncap_level = 5 if incoming.ulb
|
||||
|
||||
weapon = GridWeapon.create!(party_id: party.id, weapon_id: incoming.id,
|
||||
position: resolve_params[:position], uncap_level: uncap_level)
|
||||
|
||||
return unless weapon.save
|
||||
|
||||
view = render_grid_weapon_view(weapon, resolve_params[:position])
|
||||
render json: view, status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
render_unauthorized_response if current_user && (@weapon.party.user != current_user)
|
||||
|
||||
# TODO: Server-side validation of weapon mods
|
||||
# We don't want someone modifying the JSON and adding
|
||||
# keys to weapons that cannot have them
|
||||
|
||||
# Maybe we make methods on the model to validate for us somehow
|
||||
|
||||
@weapon.assign_attributes(weapon_params)
|
||||
|
||||
@weapon.ax_modifier1 = nil if weapon_params[:ax_modifier1] == -1
|
||||
@weapon.ax_modifier2 = nil if weapon_params[:ax_modifier2] == -1
|
||||
@weapon.ax_strength1 = nil if weapon_params[:ax_strength1]&.zero?
|
||||
@weapon.ax_strength2 = nil if weapon_params[:ax_strength2]&.zero?
|
||||
|
||||
render json: GridWeaponBlueprint.render(@weapon, view: :nested) if @weapon.save
|
||||
end
|
||||
|
||||
def destroy
|
||||
render_unauthorized_response if @weapon.party.user != current_user
|
||||
return render json: GridCharacterBlueprint.render(@weapon, view: :destroyed) if @weapon.destroy
|
||||
end
|
||||
|
||||
def update_uncap_level
|
||||
weapon = GridWeapon.find(weapon_params[:id])
|
||||
object = weapon.weapon
|
||||
max_uncap_level = max_uncap_level(object)
|
||||
|
||||
render_unauthorized_response if current_user && (weapon.party.user != current_user)
|
||||
|
||||
greater_than_max_uncap = weapon_params[:uncap_level].to_i > max_uncap_level
|
||||
can_be_transcended = object.transcendence && weapon_params[:transcendence_step] && weapon_params[:transcendence_step]&.to_i&.positive?
|
||||
|
||||
uncap_level = if greater_than_max_uncap || can_be_transcended
|
||||
max_uncap_level
|
||||
else
|
||||
weapon_params[:uncap_level]
|
||||
end
|
||||
|
||||
transcendence_step = if object.transcendence && weapon_params[:transcendence_step]
|
||||
weapon_params[:transcendence_step]
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
weapon.update!(
|
||||
uncap_level: uncap_level,
|
||||
transcendence_step: transcendence_step
|
||||
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
|
||||
new_uncap = compute_default_uncap(incoming)
|
||||
grid_weapon = GridWeapon.create!(
|
||||
party_id: @party.id,
|
||||
weapon_id: incoming.id,
|
||||
position: resolve_params[:position],
|
||||
uncap_level: new_uncap,
|
||||
transcendence_step: 0
|
||||
)
|
||||
|
||||
return unless weapon.persisted?
|
||||
if grid_weapon.persisted?
|
||||
render json: GridWeaponBlueprint.render(grid_weapon, view: :full, root: :grid_weapon, meta: { replaced: resolve_params[:position] }), status: :created
|
||||
else
|
||||
render_validation_error_response(grid_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon)
|
||||
##
|
||||
# Destroys a GridWeapon.
|
||||
#
|
||||
# Checks authorization and, if allowed, destroys the weapon and renders the destroyed view.
|
||||
#
|
||||
# @return [void]
|
||||
def destroy
|
||||
grid_weapon = GridWeapon.find_by('id = ?', params[:id])
|
||||
|
||||
return render_not_found_response('grid_weapon') if grid_weapon.nil?
|
||||
|
||||
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def max_uncap_level(weapon)
|
||||
##
|
||||
# Computes the maximum uncap level for a given weapon based on its flags.
|
||||
#
|
||||
# @param weapon [Weapon] the associated weapon.
|
||||
# @return [Integer] the maximum allowed uncap level.
|
||||
def compute_max_uncap_level(weapon)
|
||||
if weapon.flb && !weapon.ulb && !weapon.transcendence
|
||||
4
|
||||
elsif weapon.ulb && !weapon.transcendence
|
||||
|
|
@ -117,122 +150,213 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
def check_weapon_compatibility
|
||||
return if compatible_with_position?(incoming_weapon, weapon_params[:position])
|
||||
|
||||
raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: incoming_weapon)
|
||||
##
|
||||
# Computes the default uncap level for an incoming weapon.
|
||||
#
|
||||
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
|
||||
#
|
||||
# @param incoming [Weapon] the incoming weapon.
|
||||
# @return [Integer] the default uncap level.
|
||||
def compute_default_uncap(incoming)
|
||||
compute_max_uncap_level(incoming)
|
||||
end
|
||||
|
||||
# Check if the incoming weapon is compatible with the specified position
|
||||
def compatible_with_position?(incoming_weapon, position)
|
||||
false if [9, 10, 11].include?(position.to_i) && ![11, 16, 17, 28, 29, 34].include?(incoming_weapon.series)
|
||||
true
|
||||
end
|
||||
|
||||
def conflict_weapon
|
||||
@conflict_weapon ||= find_conflict_weapon(party, incoming_weapon)
|
||||
end
|
||||
|
||||
# Find a conflict weapon if one exists
|
||||
def find_conflict_weapon(party, incoming_weapon)
|
||||
return unless incoming_weapon.limit
|
||||
|
||||
party.weapons.find do |weapon|
|
||||
series_match = incoming_weapon.series == weapon.weapon.series
|
||||
weapon if series_match || opus_or_draconic?(weapon.weapon) && opus_or_draconic?(incoming_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
def find_incoming_weapon
|
||||
@incoming_weapon = Weapon.find_by(id: weapon_params[:weapon_id])
|
||||
end
|
||||
|
||||
def find_party
|
||||
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
|
||||
@party = Party.find(weapon_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
end
|
||||
|
||||
def opus_or_draconic?(weapon)
|
||||
[2, 3].include?(weapon.series)
|
||||
end
|
||||
|
||||
# Render the conflict view as a string
|
||||
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
|
||||
ConflictBlueprint.render(nil, view: :weapons,
|
||||
conflict_weapons: conflict_weapons,
|
||||
incoming_weapon: incoming_weapon,
|
||||
incoming_position: incoming_position)
|
||||
##
|
||||
# Normalizes the AX modifier fields for the weapon parameters.
|
||||
#
|
||||
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
|
||||
#
|
||||
# @return [void]
|
||||
def normalize_ax_fields!
|
||||
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
|
||||
|
||||
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
|
||||
end
|
||||
|
||||
##
|
||||
# Renders the grid weapon view.
|
||||
#
|
||||
# @param grid_weapon [GridWeapon] the grid weapon to render.
|
||||
# @param conflict_position [Integer] the position that was replaced.
|
||||
# @return [String] the rendered view.
|
||||
def render_grid_weapon_view(grid_weapon, conflict_position)
|
||||
GridWeaponBlueprint.render(grid_weapon, view: :full,
|
||||
root: :grid_weapon,
|
||||
meta: { replaced: conflict_position })
|
||||
GridWeaponBlueprint.render(grid_weapon,
|
||||
view: :full,
|
||||
root: :grid_weapon,
|
||||
meta: { replaced: conflict_position })
|
||||
end
|
||||
|
||||
##
|
||||
# Saves the GridWeapon.
|
||||
#
|
||||
# Deletes any existing grid weapon at the same position,
|
||||
# adjusts party attributes based on the weapon's position,
|
||||
# and renders the full view upon successful save.
|
||||
#
|
||||
# @param weapon [GridWeapon] the grid weapon to save.
|
||||
# @return [void]
|
||||
def save_weapon(weapon)
|
||||
# Check weapon validation and delete existing grid weapon
|
||||
# if one already exists at position
|
||||
if (grid_weapon = GridWeapon.where(
|
||||
party_id: party.id,
|
||||
position: weapon_params[:position]
|
||||
).first)
|
||||
GridWeapon.destroy(grid_weapon.id)
|
||||
# Check weapon validation and delete existing grid weapon if one already exists at position
|
||||
if (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
|
||||
existing.destroy
|
||||
end
|
||||
|
||||
# Set the party's element if the grid weapon is being set as mainhand
|
||||
if weapon.position == -1
|
||||
party.element = weapon.weapon.element
|
||||
party.save!
|
||||
elsif [9, 10, 11].include?(weapon.position)
|
||||
party.extra = true
|
||||
party.save!
|
||||
if weapon.position.to_i == -1
|
||||
@party.element = weapon.weapon.element
|
||||
@party.save!
|
||||
elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i)
|
||||
@party.extra = true
|
||||
@party.save!
|
||||
end
|
||||
|
||||
# Render the weapon if it can be saved
|
||||
return unless weapon.save
|
||||
|
||||
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
|
||||
render json: output, status: :created
|
||||
if weapon.save
|
||||
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
|
||||
render json: output, status: :created
|
||||
else
|
||||
render_validation_error_response(weapon)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Handles conflicts when a new GridWeapon fails validation.
|
||||
#
|
||||
# Retrieves the array of conflicting grid weapons (via the model’s conflicts method)
|
||||
# and either renders a conflict view (if the canonical weapons differ) or updates the
|
||||
# conflicting grid weapon's position.
|
||||
#
|
||||
# @param weapon [GridWeapon] the weapon that failed validation.
|
||||
# @return [void]
|
||||
def handle_conflict(weapon)
|
||||
conflict_weapons = weapon.conflicts(party)
|
||||
# Find if one of the conflicting grid weapons is associated with the incoming weapon.
|
||||
conflict_weapon = conflict_weapons.find { |gw| gw.weapon.id == incoming_weapon.id }
|
||||
|
||||
# Map conflict weapon IDs into an array
|
||||
conflict_weapon_ids = conflict_weapons.map(&:id)
|
||||
if !conflict_weapon_ids.include?(incoming_weapon.id)
|
||||
# Render conflict view if the underlying canonical weapons
|
||||
# are not identical
|
||||
if conflict_weapon.nil?
|
||||
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
|
||||
render json: output
|
||||
else
|
||||
# Move the original grid weapon to the new position
|
||||
# to preserve keys and other modifications
|
||||
old_position = conflict_weapon.position
|
||||
conflict_weapon.position = weapon_params[:position]
|
||||
|
||||
if conflict_weapon.save
|
||||
output = render_grid_weapon_view(conflict_weapon, old_position)
|
||||
render json: output
|
||||
else
|
||||
render_validation_error_response(conflict_weapon)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def set
|
||||
@weapon = GridWeapon.where('id = ?', params[:id]).first
|
||||
##
|
||||
# Renders the conflict view.
|
||||
#
|
||||
# @param conflict_weapons [Array<GridWeapon>] an array of conflicting grid weapons.
|
||||
# @param incoming_weapon [Weapon] the incoming weapon.
|
||||
# @param incoming_position [Integer] the desired position.
|
||||
# @return [String] the rendered conflict view.
|
||||
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
|
||||
ConflictBlueprint.render(nil,
|
||||
view: :weapons,
|
||||
conflict_weapons: conflict_weapons,
|
||||
incoming_weapon: incoming_weapon,
|
||||
incoming_position: incoming_position)
|
||||
end
|
||||
|
||||
def authorize
|
||||
# Create
|
||||
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
|
||||
unauthorized_update = @weapon && @weapon.party && (@weapon.party.user != current_user || @weapon.party.edit_key != edit_key)
|
||||
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
##
|
||||
# Finds and sets the GridWeapon based on the provided parameters.
|
||||
#
|
||||
# Searches for a grid weapon using various parameter keys and renders a not found response if it is absent.
|
||||
#
|
||||
# @return [void]
|
||||
def find_grid_weapon
|
||||
grid_weapon_id = params[:id] || params.dig(:weapon, :id) || params.dig(:resolve, :conflicting)
|
||||
@grid_weapon = GridWeapon.find_by(id: grid_weapon_id)
|
||||
render_not_found_response('grid_weapon') unless @grid_weapon
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
##
|
||||
# Finds and sets the incoming weapon.
|
||||
#
|
||||
# @return [void]
|
||||
def find_incoming_weapon
|
||||
if params.dig(:weapon, :weapon_id).present?
|
||||
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
|
||||
render_not_found_response('weapon') unless @incoming_weapon
|
||||
else
|
||||
@incoming_weapon = nil
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Finds and sets the party based on parameters.
|
||||
#
|
||||
# Renders an unauthorized response if the current user is not the owner.
|
||||
#
|
||||
# @return [void]
|
||||
def find_party
|
||||
@party = Party.find_by(id: params.dig(:weapon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_weapon&.party
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
|
||||
#
|
||||
# For parties associated with a user, it verifies that the current_user is the owner.
|
||||
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_party_edit!
|
||||
if @party.user.present?
|
||||
authorize_user_party
|
||||
else
|
||||
authorize_anonymous_party
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for a party that belongs to a user.
|
||||
#
|
||||
# Renders an unauthorized response unless the current user is present and
|
||||
# matches the party's user.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_user_party
|
||||
return if current_user.present? && @party.user == current_user
|
||||
|
||||
return render_unauthorized_response
|
||||
end
|
||||
|
||||
##
|
||||
# Authorizes an action for an anonymous party using an edit key.
|
||||
#
|
||||
# Retrieves and normalizes the provided edit key and compares it with the party's edit key.
|
||||
# Renders an unauthorized response unless the keys are valid.
|
||||
#
|
||||
# @return [void]
|
||||
def authorize_anonymous_party
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
|
||||
return render_unauthorized_response
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the provided edit key matches the party's edit key.
|
||||
#
|
||||
# @param provided_edit_key [String] the edit key provided in the request.
|
||||
# @param party_edit_key [String] the edit key associated with the party.
|
||||
# @return [Boolean] true if the edit keys match; false otherwise.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the allowed weapon parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
def weapon_params
|
||||
params.require(:weapon).permit(
|
||||
:id, :party_id, :weapon_id,
|
||||
|
|
@ -243,6 +367,10 @@ module Api
|
|||
)
|
||||
end
|
||||
|
||||
##
|
||||
# Specifies and permits the resolve parameters.
|
||||
#
|
||||
# @return [ActionController::Parameters] the permitted parameters.
|
||||
def resolve_params
|
||||
params.require(:resolve).permit(:position, :incoming, conflicting: [])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
##
|
||||
# ImportController is responsible for importing game data (e.g. deck data)
|
||||
# and creating a new Party along with associated records (job, characters, weapons, summons, etc.).
|
||||
#
|
||||
# The controller expects a JSON payload whose top-level key is "import". If not wrapped,
|
||||
# the controller will wrap the raw data automatically.
|
||||
#
|
||||
# @example Valid payload structure
|
||||
# {
|
||||
# "import": {
|
||||
# "deck": { "name": "My Party", ... },
|
||||
# "pc": { "job": { "master": { "name": "Warrior" } }, ... }
|
||||
# }
|
||||
# }
|
||||
class ImportController < Api::V1::ApiController
|
||||
ELEMENT_MAPPING = {
|
||||
0 => nil,
|
||||
|
|
@ -13,263 +27,189 @@ module Api
|
|||
6 => 5
|
||||
}.freeze
|
||||
|
||||
before_action :ensure_admin_role, only: %i[weapons summons characters]
|
||||
|
||||
##
|
||||
# Processes an import request.
|
||||
#
|
||||
# It reads and parses the raw JSON, wraps the data under the "import" key if necessary,
|
||||
# transforms the deck data using BaseDeckTransformer, validates that the transformed data
|
||||
# contains required fields, and then creates a new Party record (and its associated objects)
|
||||
# inside a transaction.
|
||||
#
|
||||
# @return [void] Renders JSON response with a party shortcode or an error message.
|
||||
def create
|
||||
Rails.logger.info "[IMPORT] Starting import..."
|
||||
Rails.logger.info '[IMPORT] Checking input...'
|
||||
|
||||
# Parse JSON request body
|
||||
raw_body = request.raw_post
|
||||
begin
|
||||
raw_params = JSON.parse(raw_body) if raw_body.present?
|
||||
Rails.logger.info "[IMPORT] Raw game data: #{raw_params.inspect}"
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "[IMPORT] Invalid JSON in request body: #{e.message}"
|
||||
render json: { error: 'Invalid JSON data' }, status: :bad_request
|
||||
return
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
raw_params = body['import']
|
||||
unless raw_params.is_a?(Hash)
|
||||
Rails.logger.error "[IMPORT] 'import' key is missing or not a hash."
|
||||
return render json: { error: 'Invalid JSON data' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if raw_params.nil? || !raw_params.is_a?(Hash)
|
||||
Rails.logger.error "[IMPORT] Missing or invalid game data"
|
||||
render json: { error: 'Missing or invalid game data' }, status: :bad_request
|
||||
return
|
||||
unless raw_params['deck'].is_a?(Hash) &&
|
||||
raw_params['deck'].key?('pc') &&
|
||||
raw_params['deck'].key?('npc')
|
||||
Rails.logger.error '[IMPORT] Deck data incomplete or missing.'
|
||||
return render json: { error: 'Invalid deck data' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
# Transform game data
|
||||
transformer = ::Granblue::Transformers::BaseDeckTransformer.new(raw_params)
|
||||
transformed_data = transformer.transform
|
||||
Rails.logger.info "[IMPORT] Transformed data: #{transformed_data.inspect}"
|
||||
Rails.logger.info '[IMPORT] Starting import...'
|
||||
|
||||
# Validate transformed data
|
||||
unless transformed_data[:name].present? && transformed_data[:lang].present?
|
||||
Rails.logger.error "[IMPORT] Missing required fields in transformed data"
|
||||
render json: { error: 'Missing required fields name or lang' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
return if performed? # Rendered an error response already
|
||||
|
||||
# Create party
|
||||
party = Party.new(user: current_user)
|
||||
party = Party.create(user: current_user)
|
||||
deck_data = raw_params['import']
|
||||
process_data(party, deck_data)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Basic party data
|
||||
party.name = transformed_data[:name]
|
||||
party.extra = transformed_data[:extra]
|
||||
party.save!
|
||||
|
||||
# Process job and skills
|
||||
if transformed_data[:class].present?
|
||||
process_job(party, transformed_data[:class], transformed_data[:subskills])
|
||||
end
|
||||
|
||||
# Process characters
|
||||
if transformed_data[:characters].present?
|
||||
process_characters(party, transformed_data[:characters])
|
||||
end
|
||||
|
||||
# Process weapons
|
||||
if transformed_data[:weapons].present?
|
||||
process_weapons(party, transformed_data[:weapons])
|
||||
end
|
||||
|
||||
# Process summons
|
||||
if transformed_data[:summons].present?
|
||||
process_summons(party, transformed_data[:summons], transformed_data[:friend_summon])
|
||||
end
|
||||
|
||||
# Process sub summons
|
||||
if transformed_data[:sub_summons].present?
|
||||
process_sub_summons(party, transformed_data[:sub_summons])
|
||||
end
|
||||
end
|
||||
|
||||
# Return shortcode for redirection
|
||||
render json: { shortcode: party.shortcode }, status: :created
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Error processing import: #{e.message}"
|
||||
Rails.logger.error "[IMPORT] Backtrace: #{e.backtrace.join("\n")}"
|
||||
render json: { error: 'Error processing import' }, status: :unprocessable_entity
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def weapons
|
||||
Rails.logger.info '[IMPORT] Checking weapon gamedata input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
weapon = Weapon.find_by(granblue_id: body['id'])
|
||||
unless weapon
|
||||
Rails.logger.error "[IMPORT] Weapon not found with ID: #{body['id']}"
|
||||
return render json: { error: 'Weapon not found' }, status: :not_found
|
||||
end
|
||||
|
||||
lang = params[:lang]
|
||||
unless %w[en jp].include?(lang)
|
||||
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
|
||||
return render json: { error: 'Invalid language' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
begin
|
||||
weapon.update!(
|
||||
"game_raw_#{lang}" => body.to_json
|
||||
)
|
||||
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def summons
|
||||
Rails.logger.info '[IMPORT] Checking summon gamedata input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
summon = Summon.find_by(granblue_id: body['id'])
|
||||
unless summon
|
||||
Rails.logger.error "[IMPORT] Summon not found with ID: #{body['id']}"
|
||||
return render json: { error: 'Summon not found' }, status: :not_found
|
||||
end
|
||||
|
||||
lang = params[:lang]
|
||||
unless %w[en jp].include?(lang)
|
||||
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
|
||||
return render json: { error: 'Invalid language' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
begin
|
||||
summon.update!(
|
||||
"game_raw_#{lang}" => body.to_json
|
||||
)
|
||||
render json: { message: 'Summon gamedata updated successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Updates character gamedata from JSON blob.
|
||||
#
|
||||
# @return [void] Renders JSON response with success or error message.
|
||||
def characters
|
||||
Rails.logger.info '[IMPORT] Checking character gamedata input...'
|
||||
|
||||
body = parse_request_body
|
||||
return unless body
|
||||
|
||||
character = Character.find_by(granblue_id: body['id'])
|
||||
unless character
|
||||
Rails.logger.error "[IMPORT] Character not found with ID: #{body['id']}"
|
||||
return render json: { error: 'Character not found' }, status: :not_found
|
||||
end
|
||||
|
||||
lang = params[:lang]
|
||||
unless %w[en jp].include?(lang)
|
||||
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
|
||||
return render json: { error: 'Invalid language' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
begin
|
||||
character.update!(
|
||||
"game_raw_#{lang}" => body.to_json
|
||||
)
|
||||
render json: { message: 'Character gamedata updated successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_job(party, job_name, subskills)
|
||||
return unless job_name
|
||||
job = Job.find_by("name_en = ? OR name_jp = ?", job_name, job_name)
|
||||
unless job
|
||||
Rails.logger.warn "[IMPORT] Could not find job: #{job_name}"
|
||||
return
|
||||
end
|
||||
##
|
||||
# Ensures the current user has admin role (role 9).
|
||||
# Renders an error if the user is not an admin.
|
||||
#
|
||||
# @return [void]
|
||||
def ensure_admin_role
|
||||
return if current_user&.role == 9
|
||||
|
||||
party.job = job
|
||||
party.save!
|
||||
Rails.logger.info "[IMPORT] Assigned job=#{job_name} to party_id=#{party.id}"
|
||||
|
||||
return unless subskills&.any?
|
||||
subskills.each_with_index do |skill_name, idx|
|
||||
next if skill_name.blank?
|
||||
skill = JobSkill.find_by("(name_en = ? OR name_jp = ?) AND job_id = ?", skill_name, skill_name, job.id)
|
||||
unless skill
|
||||
Rails.logger.warn "[IMPORT] Could not find skill=#{skill_name} for job_id=#{job.id}"
|
||||
next
|
||||
end
|
||||
party["skill#{idx + 1}_id"] = skill.id
|
||||
Rails.logger.info "[IMPORT] Assigned skill=#{skill_name} at position #{idx + 1}"
|
||||
end
|
||||
Rails.logger.error "[IMPORT] Unauthorized access attempt by user #{current_user&.id}"
|
||||
render json: { error: 'Unauthorized' }, status: :unauthorized
|
||||
end
|
||||
|
||||
def process_characters(party, characters)
|
||||
return unless characters&.any?
|
||||
Rails.logger.info "[IMPORT] Processing #{characters.length} characters"
|
||||
|
||||
characters.each_with_index do |char_data, idx|
|
||||
character = Character.find_by(granblue_id: char_data[:id])
|
||||
unless character
|
||||
Rails.logger.warn "[IMPORT] Character not found: #{char_data[:id]}"
|
||||
next
|
||||
end
|
||||
|
||||
GridCharacter.create!(
|
||||
party: party,
|
||||
character_id: character.id,
|
||||
position: idx,
|
||||
uncap_level: char_data[:uncap],
|
||||
perpetuity: char_data[:ringed] || false,
|
||||
transcendence_step: char_data[:transcend] || 0
|
||||
)
|
||||
Rails.logger.info "[IMPORT] Added character: #{character.name_en} at position #{idx}"
|
||||
end
|
||||
##
|
||||
# Reads and parses the raw JSON request body.
|
||||
#
|
||||
# @return [Hash] Parsed JSON data.
|
||||
# @raise [JSON::ParserError] If the JSON is invalid.
|
||||
def parse_request_body
|
||||
raw_body = request.raw_post
|
||||
JSON.parse(raw_body)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "[IMPORT] Invalid JSON: #{e.message}"
|
||||
render json: { error: 'Invalid JSON data' }, status: :bad_request and return
|
||||
end
|
||||
|
||||
def process_weapons(party, weapons)
|
||||
return unless weapons&.any?
|
||||
Rails.logger.info "[IMPORT] Processing #{weapons.length} weapons"
|
||||
|
||||
weapons.each_with_index do |weapon_data, idx|
|
||||
weapon = Weapon.find_by(granblue_id: weapon_data[:id])
|
||||
unless weapon
|
||||
Rails.logger.warn "[IMPORT] Weapon not found: #{weapon_data[:id]}"
|
||||
next
|
||||
end
|
||||
|
||||
grid_weapon = GridWeapon.create!(
|
||||
party: party,
|
||||
weapon_id: weapon.id,
|
||||
position: idx - 1,
|
||||
mainhand: idx.zero?,
|
||||
uncap_level: weapon_data[:uncap],
|
||||
transcendence_step: weapon_data[:transcend] || 0,
|
||||
element: weapon_data[:attr] ? ELEMENT_MAPPING[weapon_data[:attr]] : nil
|
||||
)
|
||||
|
||||
process_weapon_keys(grid_weapon, weapon_data[:keys]) if weapon_data[:keys]
|
||||
process_weapon_ax(grid_weapon, weapon_data[:ax]) if weapon_data[:ax]
|
||||
|
||||
Rails.logger.info "[IMPORT] Added weapon: #{weapon.name_en} at position #{idx - 1}"
|
||||
end
|
||||
##
|
||||
# Ensures that the provided data is wrapped under an "import" key.
|
||||
#
|
||||
# @param data [Hash] The parsed JSON data.
|
||||
# @return [Hash] Data wrapped under the "import" key.
|
||||
def wrap_import_data(data)
|
||||
data.key?('import') ? data : { 'import' => data }
|
||||
end
|
||||
|
||||
def process_weapon_keys(grid_weapon, keys)
|
||||
keys.each_with_index do |key_id, idx|
|
||||
key = WeaponKey.find_by(granblue_id: key_id)
|
||||
unless key
|
||||
Rails.logger.warn "[IMPORT] WeaponKey not found: #{key_id}"
|
||||
next
|
||||
end
|
||||
grid_weapon["weapon_key#{idx + 1}_id"] = key.id
|
||||
grid_weapon.save!
|
||||
end
|
||||
end
|
||||
##
|
||||
# Processes the deck data using processors.
|
||||
#
|
||||
# @param party [Party] The party to insert data into
|
||||
# @param data [Hash] The wrapped data.
|
||||
# @return [Hash] The transformed deck data.
|
||||
def process_data(party, data)
|
||||
Rails.logger.info '[IMPORT] Transforming deck data'
|
||||
|
||||
def process_weapon_ax(grid_weapon, ax_skills)
|
||||
ax_skills.each_with_index do |ax, idx|
|
||||
grid_weapon["ax_modifier#{idx + 1}"] = ax[:id].to_i
|
||||
grid_weapon["ax_strength#{idx + 1}"] = ax[:val].to_s.gsub(/[+%]/, '').to_i
|
||||
end
|
||||
grid_weapon.save!
|
||||
end
|
||||
|
||||
def process_summons(party, summons, friend_summon = nil)
|
||||
return unless summons&.any?
|
||||
Rails.logger.info "[IMPORT] Processing #{summons.length} summons"
|
||||
|
||||
# Main and sub summons
|
||||
summons.each_with_index do |summon_data, idx|
|
||||
summon = Summon.find_by(granblue_id: summon_data[:id])
|
||||
unless summon
|
||||
Rails.logger.warn "[IMPORT] Summon not found: #{summon_data[:id]}"
|
||||
next
|
||||
end
|
||||
|
||||
grid_summon = GridSummon.new(
|
||||
party: party,
|
||||
summon_id: summon.id,
|
||||
position: idx,
|
||||
main: idx.zero?,
|
||||
friend: false,
|
||||
uncap_level: summon_data[:uncap],
|
||||
transcendence_step: summon_data[:transcend] || 0,
|
||||
quick_summon: summon_data[:qs] || false
|
||||
)
|
||||
|
||||
if grid_summon.save
|
||||
Rails.logger.info "[IMPORT] Added summon: #{summon.name_en} at position #{idx}"
|
||||
else
|
||||
Rails.logger.error "[IMPORT] Failed to save summon: #{grid_summon.errors.full_messages}"
|
||||
end
|
||||
end
|
||||
|
||||
# Friend summon if provided
|
||||
process_friend_summon(party, friend_summon) if friend_summon.present?
|
||||
end
|
||||
|
||||
def process_friend_summon(party, friend_summon)
|
||||
friend = Summon.find_by("name_en = ? OR name_jp = ?", friend_summon, friend_summon)
|
||||
unless friend
|
||||
Rails.logger.warn "[IMPORT] Friend summon not found: #{friend_summon}"
|
||||
return
|
||||
end
|
||||
|
||||
grid_summon = GridSummon.new(
|
||||
party: party,
|
||||
summon_id: friend.id,
|
||||
position: 6,
|
||||
main: false,
|
||||
friend: true,
|
||||
uncap_level: friend.ulb ? 5 : (friend.flb ? 4 : 3)
|
||||
)
|
||||
|
||||
if grid_summon.save
|
||||
Rails.logger.info "[IMPORT] Added friend summon: #{friend.name_en}"
|
||||
else
|
||||
Rails.logger.error "[IMPORT] Failed to save friend summon: #{grid_summon.errors.full_messages}"
|
||||
end
|
||||
end
|
||||
|
||||
def process_sub_summons(party, sub_summons)
|
||||
return unless sub_summons&.any?
|
||||
Rails.logger.info "[IMPORT] Processing #{sub_summons.length} sub summons"
|
||||
|
||||
sub_summons.each_with_index do |summon_data, idx|
|
||||
summon = Summon.find_by(granblue_id: summon_data[:id])
|
||||
unless summon
|
||||
Rails.logger.warn "[IMPORT] Sub summon not found: #{summon_data[:id]}"
|
||||
next
|
||||
end
|
||||
|
||||
grid_summon = GridSummon.new(
|
||||
party: party,
|
||||
summon_id: summon.id,
|
||||
position: idx + 5,
|
||||
main: false,
|
||||
friend: false,
|
||||
uncap_level: summon_data[:uncap],
|
||||
transcendence_step: summon_data[:transcend] || 0
|
||||
)
|
||||
|
||||
if grid_summon.save
|
||||
Rails.logger.info "[IMPORT] Added sub summon: #{summon.name_en} at position #{idx + 5}"
|
||||
else
|
||||
Rails.logger.error "[IMPORT] Failed to save sub summon: #{grid_summon.errors.full_messages}"
|
||||
end
|
||||
end
|
||||
Processors::JobProcessor.new(party, data).process
|
||||
Processors::CharacterProcessor.new(party, data).process
|
||||
Processors::SummonProcessor.new(party, data).process
|
||||
Processors::WeaponProcessor.new(party, data).process
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ module Api
|
|||
|
||||
# Remove extra subskills if necessary
|
||||
if old_job &&
|
||||
%w[1 2 3].include?(old_job.row) &&
|
||||
%w[4 5 ex2].include?(job.row) &&
|
||||
@party.skill1 && @party.skill2 && @party.skill3 &&
|
||||
@party.skill1.sub && @party.skill2.sub && @party.skill3.sub
|
||||
%w[1 2 3].include?(old_job.row) &&
|
||||
%w[4 5 ex2].include?(job.row) &&
|
||||
@party.skill1 && @party.skill2 && @party.skill3 &&
|
||||
@party.skill1.sub && @party.skill2.sub && @party.skill3.sub
|
||||
@party['skill3_id'] = nil
|
||||
end
|
||||
else
|
||||
|
|
@ -47,7 +47,7 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
|
||||
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
|
||||
end
|
||||
|
||||
def update_job_skills
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ module Api
|
|||
# Controller for managing party-related operations in the API
|
||||
# @api public
|
||||
class PartiesController < Api::V1::ApiController
|
||||
before_action :set_from_slug,
|
||||
except: %w[create destroy update index favorites]
|
||||
before_action :set, only: %w[update destroy]
|
||||
before_action :authorize, only: %w[update destroy]
|
||||
include PartyAuthorizationConcern
|
||||
include PartyQueryingConcern
|
||||
include PartyPreviewConcern
|
||||
|
||||
# == Constants
|
||||
# Constants used for filtering validations.
|
||||
|
||||
# Maximum number of characters allowed in a party
|
||||
MAX_CHARACTERS = 5
|
||||
|
|
@ -33,728 +32,167 @@ module Api
|
|||
# Default maximum clear time in seconds
|
||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||
|
||||
# == Primary CRUD Actions
|
||||
before_action :set_from_slug, except: %w[create destroy update index favorites]
|
||||
before_action :set, only: %w[update destroy]
|
||||
before_action :authorize_party!, only: %w[update destroy]
|
||||
|
||||
# Primary CRUD Actions
|
||||
|
||||
# Creates a new party with optional user association
|
||||
# @return [void]
|
||||
# Creates a new party.
|
||||
def create
|
||||
# Build the party with the provided parameters and assign the user
|
||||
party = Party.new(party_params)
|
||||
party.user = current_user if current_user
|
||||
|
||||
# If a raid_id is given, look it up and assign the extra flag from its group.
|
||||
if party_params && party_params[:raid_id].present?
|
||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||
party.extra = raid.group.extra
|
||||
end
|
||||
end
|
||||
|
||||
# Save and render the party, triggering preview generation if the party is ready
|
||||
if party.save
|
||||
party.schedule_preview_generation if party.ready_for_preview?
|
||||
render json: PartyBlueprint.render(party, view: :created, root: :party),
|
||||
status: :created
|
||||
render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
|
||||
else
|
||||
render_validation_error_response(party)
|
||||
end
|
||||
end
|
||||
|
||||
# Shows a specific party if the user has permission to view it
|
||||
# @return [void]
|
||||
# Shows a specific party.
|
||||
def show
|
||||
# If a party is private, check that the user is the owner or an admin
|
||||
if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode)
|
||||
return render_unauthorized_response
|
||||
return render_unauthorized_response if @party.private? && (!current_user || not_owner?)
|
||||
|
||||
if @party
|
||||
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
||||
else
|
||||
render_not_found_response('project')
|
||||
end
|
||||
|
||||
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party
|
||||
|
||||
render_not_found_response('project')
|
||||
end
|
||||
|
||||
# Updates an existing party's attributes
|
||||
# @return [void]
|
||||
# Updates an existing party.
|
||||
def update
|
||||
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
||||
|
||||
if party_params && party_params[:raid_id]
|
||||
raid = Raid.find_by(id: party_params[:raid_id])
|
||||
@party.extra = raid.group.extra
|
||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||
@party.extra = raid.group.extra
|
||||
end
|
||||
end
|
||||
if @party.save
|
||||
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
||||
else
|
||||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# TODO: Validate accessory with job
|
||||
|
||||
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party.save
|
||||
|
||||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# Deletes a party if the user has permission
|
||||
# @return [void]
|
||||
# Deletes a party.
|
||||
def destroy
|
||||
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
end
|
||||
|
||||
# == Extended Party Actions
|
||||
# Extended Party Actions
|
||||
|
||||
# Creates a copy of an existing party with attribution
|
||||
# @return [void]
|
||||
# Creates a remixed copy of an existing party.
|
||||
def remix
|
||||
new_party = @party.amoeba_dup
|
||||
new_party.attributes = {
|
||||
user: current_user,
|
||||
name: remixed_name(@party.name),
|
||||
source_party: @party,
|
||||
remix: true
|
||||
}
|
||||
|
||||
new_party.local_id = party_params[:local_id] unless party_params.nil?
|
||||
|
||||
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
|
||||
new_party.local_id = party_params[:local_id] if party_params
|
||||
if new_party.save
|
||||
# Remixed parties should have content, so generate preview
|
||||
new_party.schedule_preview_generation
|
||||
render json: PartyBlueprint.render(new_party, view: :created, root: :party),
|
||||
status: :created
|
||||
render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created
|
||||
else
|
||||
render_validation_error_response(new_party)
|
||||
end
|
||||
end
|
||||
|
||||
# Lists parties based on various filter criteria
|
||||
# @return [void]
|
||||
# Lists parties based on query parameters.
|
||||
def index
|
||||
query = build_parties_query
|
||||
query = build_filtered_query(build_common_base_query)
|
||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
|
||||
# Lists parties favorited by the current user
|
||||
# @return [void]
|
||||
# GET /api/v1/parties/favorites
|
||||
def favorites
|
||||
raise Api::V1::UnauthorizedError unless current_user
|
||||
|
||||
query = build_parties_query(favorites: true)
|
||||
base_query = build_common_base_query
|
||||
.joins(:favorites)
|
||||
.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)
|
||||
# Mark each party as favorited (if needed)
|
||||
@parties.each { |party| party.favorited = true }
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
|
||||
# == Preview Management
|
||||
# Preview Management
|
||||
|
||||
# Serves the party's preview image
|
||||
# @return [void]
|
||||
# Serves the party's preview image.
|
||||
def preview
|
||||
coordinator = PreviewService::Coordinator.new(@party)
|
||||
|
||||
if coordinator.generation_in_progress?
|
||||
response.headers['Retry-After'] = '2'
|
||||
default_path = Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png")
|
||||
send_file default_path,
|
||||
type: 'image/png',
|
||||
disposition: 'inline'
|
||||
return
|
||||
end
|
||||
|
||||
# Try to get the preview or send default
|
||||
begin
|
||||
if Rails.env.production?
|
||||
# Stream S3 content instead of redirecting
|
||||
s3_object = coordinator.get_s3_object
|
||||
send_data s3_object.body.read,
|
||||
filename: "#{@party.shortcode}.png",
|
||||
type: 'image/png',
|
||||
disposition: 'inline'
|
||||
else
|
||||
# In development, serve from local filesystem
|
||||
send_file coordinator.local_preview_path,
|
||||
type: 'image/png',
|
||||
disposition: 'inline'
|
||||
end
|
||||
rescue Aws::S3::Errors::NoSuchKey
|
||||
# Schedule generation if needed
|
||||
coordinator.schedule_generation unless coordinator.generation_in_progress?
|
||||
|
||||
# Return default preview while generating
|
||||
send_file Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png"),
|
||||
type: 'image/png',
|
||||
disposition: 'inline'
|
||||
end
|
||||
party_preview(@party)
|
||||
end
|
||||
|
||||
# Returns the current status of a party's preview
|
||||
# @return [void]
|
||||
# Returns the current preview status of a party.
|
||||
def preview_status
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
render json: {
|
||||
state: party.preview_state,
|
||||
generated_at: party.preview_generated_at,
|
||||
ready_for_preview: party.ready_for_preview?
|
||||
}
|
||||
render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? }
|
||||
end
|
||||
|
||||
# Forces regeneration of a party's preview image
|
||||
# @return [void]
|
||||
# Forces regeneration of the party preview.
|
||||
def regenerate_preview
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
|
||||
# Ensure only party owner can force regeneration
|
||||
unless current_user && party.user_id == current_user.id
|
||||
return render_unauthorized_response
|
||||
end
|
||||
return render_unauthorized_response unless current_user && party.user_id == current_user.id
|
||||
|
||||
preview_service = PreviewService::Coordinator.new(party)
|
||||
if preview_service.force_regenerate
|
||||
render json: { status: 'Preview regeneration started' }
|
||||
else
|
||||
render json: { error: 'Preview regeneration failed' },
|
||||
status: :unprocessable_entity
|
||||
render json: { error: 'Preview regeneration failed' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Builds the base query for parties, optionally including favorites-specific conditions.
|
||||
def build_parties_query(favorites: false)
|
||||
query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
# Add favorites join and condition if favorites is true.
|
||||
if favorites
|
||||
query = query.joins(:favorites)
|
||||
.where(favorites: { user_id: current_user.id })
|
||||
.distinct
|
||||
query = query.order(created_at: :desc)
|
||||
else
|
||||
query = query.order(visibility: :asc, created_at: :desc)
|
||||
end
|
||||
|
||||
query = apply_filters(query)
|
||||
query = apply_privacy_settings(query)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
query
|
||||
end
|
||||
|
||||
# Renders the paginated parties with blueprint and meta data.
|
||||
def render_paginated_parties(parties)
|
||||
render json: PartyBlueprint.render(
|
||||
parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: parties.total_entries,
|
||||
total_pages: parties.total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
},
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
|
||||
# == Authorization Helpers
|
||||
|
||||
# Checks if the current user is authorized to modify the party
|
||||
# @return [void]
|
||||
def authorize
|
||||
return unless not_owner && !admin_mode
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
# Determines if the current user is not the owner of the party
|
||||
# @return [Boolean]
|
||||
def not_owner
|
||||
if @party.user
|
||||
# party has a user and current_user does not match
|
||||
return true if current_user != @party.user
|
||||
|
||||
# party has a user, there's no current_user, but edit_key is provided
|
||||
return true if current_user.nil? && edit_key
|
||||
else
|
||||
# party has no user, there's no current_user and there's no edit_key provided
|
||||
return true if current_user.nil? && edit_key.nil?
|
||||
|
||||
# party has no user, there's no current_user, and the party's edit_key doesn't match the provided edit_key
|
||||
return true if current_user.nil? && @party.edit_key != edit_key
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# == Preview Generation
|
||||
|
||||
# Schedules a background job to generate the party preview
|
||||
# @return [void]
|
||||
def schedule_preview_generation
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
# == Query Building Helpers
|
||||
|
||||
def apply_filters(query)
|
||||
conditions = build_filters
|
||||
|
||||
# Use the compound indexes effectively
|
||||
query = query.where(conditions)
|
||||
.where(name_quality) if params[:name_quality].present?
|
||||
|
||||
# Use the counters index
|
||||
query = query.where(
|
||||
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS,
|
||||
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
|
||||
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS
|
||||
)
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def apply_privacy_settings(query)
|
||||
return query if admin_mode
|
||||
|
||||
if params[:favorites].present?
|
||||
query.where('visibility < 3')
|
||||
else
|
||||
query.where(visibility: 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Builds filter conditions from request parameters
|
||||
# @return [Hash] conditions for the query
|
||||
def build_filters
|
||||
{
|
||||
element: params[:element].present? ? params[:element].to_i : nil,
|
||||
raid_id: params[:raid],
|
||||
created_at: build_date_range,
|
||||
full_auto: build_option(params[:full_auto]),
|
||||
auto_guard: build_option(params[:auto_guard]),
|
||||
charge_attack: build_option(params[:charge_attack]),
|
||||
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
|
||||
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS,
|
||||
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS
|
||||
}.compact
|
||||
end
|
||||
|
||||
def build_date_range
|
||||
return nil unless params[:recency].present?
|
||||
|
||||
start_time = DateTime.current - params[:recency].to_i.seconds
|
||||
start_time.beginning_of_day..DateTime.current
|
||||
end
|
||||
|
||||
# Paginates the given query of parties and marks favorites for the current user
|
||||
#
|
||||
# @param query [ActiveRecord::Relation] The base query containing parties
|
||||
# @param page [Integer, nil] The page number for pagination (defaults to `params[:page]`)
|
||||
# @param per_page [Integer] The number of records per page (defaults to `COLLECTION_PER_PAGE`)
|
||||
# @return [ActiveRecord::Relation] The paginated and processed list of parties
|
||||
#
|
||||
# This method orders parties by creation date in descending order, applies pagination,
|
||||
# and marks each party as favorited if the current user has favorited it.
|
||||
def paginate_parties(query, page: nil, per_page: COLLECTION_PER_PAGE)
|
||||
query.order(created_at: :desc)
|
||||
.paginate(page: page || params[:page], per_page: per_page)
|
||||
.tap do |parties|
|
||||
if current_user
|
||||
parties.each { |party| party.favorited = party.is_favorited(current_user) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Parameter Processing Helpers
|
||||
|
||||
# Converts start time parameter for filtering
|
||||
# @param recency [String, nil] time period in seconds
|
||||
# @return [DateTime, nil] calculated start time
|
||||
def build_start_time(recency)
|
||||
return unless recency.present?
|
||||
|
||||
(DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day
|
||||
end
|
||||
|
||||
# Builds count parameter with default fallback
|
||||
# @param value [String, nil] count value
|
||||
# @param default [Integer] default value
|
||||
# @return [Integer] processed count
|
||||
def build_count(value, default)
|
||||
value.blank? ? default : value.to_i
|
||||
end
|
||||
|
||||
# Processes maximum clear time parameter
|
||||
# @param value [String, nil] clear time value in seconds
|
||||
# @return [Integer] processed maximum clear time
|
||||
def build_max_clear_time(value)
|
||||
value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i
|
||||
end
|
||||
|
||||
# Processes element parameter
|
||||
# @param element [String, nil] element identifier
|
||||
# @return [Integer, nil] processed element value
|
||||
def build_element(element)
|
||||
element.to_i unless element.blank?
|
||||
end
|
||||
|
||||
# Processes boolean option parameters
|
||||
# @param value [String, nil] option value
|
||||
# @return [Integer, nil] processed option value
|
||||
def build_option(value)
|
||||
value.to_i unless value.blank? || value.to_i == -1
|
||||
end
|
||||
|
||||
# == Query Building Helpers
|
||||
|
||||
# Constructs the main query for party filtering
|
||||
# @param conditions [Hash] filter conditions
|
||||
# @param favorites [Boolean] whether to include favorites
|
||||
# @return [ActiveRecord::Relation] constructed query
|
||||
def build_query(conditions, favorites: false)
|
||||
query = Party.distinct
|
||||
# joins vs includes? -> reduces n+1s
|
||||
.preload(
|
||||
weapons: { object: %i[name_en name_jp granblue_id element] },
|
||||
summons: { object: %i[name_en name_jp granblue_id element] },
|
||||
characters: { object: %i[name_en name_jp granblue_id element] }
|
||||
)
|
||||
.group('parties.id')
|
||||
.where(conditions)
|
||||
.where(privacy(favorites: favorites))
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
|
||||
query = query.includes(:favorites) if favorites
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
# Applies the include conditions to query
|
||||
# @param query [ActiveRecord::Relation] base query
|
||||
# @param includes [String] comma-separated list of IDs to include
|
||||
# @return [ActiveRecord::Relation] modified query
|
||||
def apply_includes(query, includes)
|
||||
return query unless includes.present?
|
||||
|
||||
includes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
|
||||
# Build a subquery that joins the grid table to the object table.
|
||||
condition = <<-SQL.squish
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ?
|
||||
AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
# Applies the exclude conditions to query
|
||||
# @param query [ActiveRecord::Relation] base query
|
||||
# @return [ActiveRecord::Relation] modified query
|
||||
def apply_excludes(query, excludes)
|
||||
return query unless excludes.present?
|
||||
|
||||
excludes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
|
||||
condition = <<-SQL.squish
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ?
|
||||
AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
# == Query Filtering Helpers
|
||||
|
||||
# Generates subquery for excluded characters
|
||||
# @return [ActiveRecord::Relation, nil] exclusion query
|
||||
def excluded_characters
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '3' }
|
||||
GridCharacter.includes(:object)
|
||||
.where(characters: { granblue_id: excluded })
|
||||
.where('grid_characters.party_id = parties.id')
|
||||
end
|
||||
|
||||
# Generates subquery for excluded summons
|
||||
# @return [ActiveRecord::Relation, nil] exclusion query
|
||||
def excluded_summons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '2' }
|
||||
GridSummon.includes(:object)
|
||||
.where(summons: { granblue_id: excluded })
|
||||
.where('grid_summons.party_id = parties.id')
|
||||
end
|
||||
|
||||
# Generates subquery for excluded weapons
|
||||
# @return [ActiveRecord::Relation, nil] exclusion query
|
||||
def excluded_weapons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '1' }
|
||||
GridWeapon.includes(:object)
|
||||
.where(weapons: { granblue_id: excluded })
|
||||
.where('grid_weapons.party_id = parties.id')
|
||||
end
|
||||
|
||||
# == Query Processing
|
||||
|
||||
# Fetches and processes parties query with pagination
|
||||
# @param query [ActiveRecord::Relation] base query
|
||||
# @return [ActiveRecord::Relation] processed and paginated parties
|
||||
def fetch_parties(query)
|
||||
query.order(created_at: :desc)
|
||||
.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false }
|
||||
end
|
||||
|
||||
# Calculates total count for pagination
|
||||
# @param query [ActiveRecord::Relation] current query
|
||||
# @return [Integer] total count
|
||||
def calculate_count(query)
|
||||
# query.count.values.sum
|
||||
query.count
|
||||
end
|
||||
|
||||
# Calculates total pages for pagination
|
||||
# @param count [Integer] total record count
|
||||
# @return [Integer] total pages
|
||||
def calculate_total_pages(count)
|
||||
# count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
|
||||
(count.to_f / COLLECTION_PER_PAGE).ceil
|
||||
end
|
||||
|
||||
# == Include/Exclude Processing
|
||||
|
||||
# Generates SQL for including specific items
|
||||
# @param id [String] item identifier
|
||||
# @return [String] SQL condition
|
||||
def includes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
|
||||
end
|
||||
|
||||
# Generates SQL for excluding specific items
|
||||
# @param id [String] item identifier
|
||||
# @return [String] SQL condition
|
||||
def excludes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
|
||||
end
|
||||
|
||||
# == Filter Condition Helpers
|
||||
|
||||
# Generates user quality condition
|
||||
# @return [String, nil] SQL condition for user quality
|
||||
def user_quality
|
||||
return if params[:user_quality].blank? || params[:user_quality] == 'false'
|
||||
|
||||
'user_id IS NOT NULL'
|
||||
end
|
||||
|
||||
# Generates name quality condition
|
||||
# @return [String, nil] SQL condition for name quality
|
||||
def name_quality
|
||||
return if params[:name_quality].blank? || params[:name_quality] == 'false'
|
||||
|
||||
low_quality = [
|
||||
'Untitled',
|
||||
'Remix of Untitled',
|
||||
'Remix of Remix of Untitled',
|
||||
'Remix of Remix of Remix of Untitled',
|
||||
'Remix of Remix of Remix of Remix of Untitled',
|
||||
'Remix of Remix of Remix of Remix of Remix of Untitled',
|
||||
'無題',
|
||||
'無題のリミックス',
|
||||
'無題のリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックスのリミックスのリミックス'
|
||||
]
|
||||
joined_names = low_quality.map { |name| "'#{name}'" }.join(',')
|
||||
"name NOT IN (#{joined_names})"
|
||||
end
|
||||
|
||||
# Generates original party condition
|
||||
# @return [String, nil] SQL condition for original parties
|
||||
def original
|
||||
return if params['original'].blank? || params['original'] == 'false'
|
||||
|
||||
'source_party_id IS NULL'
|
||||
end
|
||||
|
||||
# == Filter Condition Helpers
|
||||
|
||||
# Generates privacy condition based on favorites
|
||||
# @param favorites [Boolean] whether viewing favorites
|
||||
# @return [String, nil] SQL condition
|
||||
def privacy(favorites: false)
|
||||
return if admin_mode
|
||||
|
||||
if favorites
|
||||
'visibility < 3'
|
||||
else
|
||||
'visibility = 1'
|
||||
end
|
||||
end
|
||||
|
||||
# == Utility Methods
|
||||
|
||||
# Maps ID prefixes to table names
|
||||
# @param id [String] item identifier
|
||||
# @return [Array(String, String)] corresponding table name
|
||||
def grid_table_and_object_table(id)
|
||||
case id[0]
|
||||
when '3'
|
||||
%w[grid_characters characters]
|
||||
when '2'
|
||||
%w[grid_summons summons]
|
||||
when '1'
|
||||
%w[grid_weapons weapons]
|
||||
else
|
||||
[nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
# Generates name for remixed party
|
||||
# @param name [String] original party name
|
||||
# @return [String] generated remix name
|
||||
def remixed_name(name)
|
||||
blanked_name = {
|
||||
en: name.blank? ? 'Untitled team' : name,
|
||||
ja: name.blank? ? '無名の編成' : name
|
||||
}
|
||||
|
||||
if current_user
|
||||
case current_user.language
|
||||
when 'en'
|
||||
"Remix of #{blanked_name[:en]}"
|
||||
when 'ja'
|
||||
"#{blanked_name[:ja]}のリミックス"
|
||||
end
|
||||
else
|
||||
"Remix of #{blanked_name[:en]}"
|
||||
end
|
||||
end
|
||||
|
||||
# == Party Loading
|
||||
|
||||
# Loads party by shortcode for routes using :id
|
||||
# @return [void]
|
||||
# Loads the party by its shortcode.
|
||||
def set_from_slug
|
||||
@party = Party.includes(
|
||||
:user,
|
||||
:job,
|
||||
{ raid: :group },
|
||||
{ characters: [:character, :awakening] },
|
||||
{
|
||||
weapons: {
|
||||
# Eager load the associated weapon and its awakenings.
|
||||
weapon: [:awakenings],
|
||||
# Eager load the grid weapon’s own awakening (if applicable).
|
||||
awakening: {},
|
||||
# Eager load any weapon key associations.
|
||||
weapon_key1: {},
|
||||
weapon_key2: {},
|
||||
weapon_key3: {}
|
||||
}
|
||||
:user, :job, { raid: :group },
|
||||
{ characters: %i[character awakening] },
|
||||
{ weapons: {
|
||||
weapon: [:awakenings],
|
||||
awakening: {},
|
||||
weapon_key1: {},
|
||||
weapon_key2: {},
|
||||
weapon_key3: {}
|
||||
}
|
||||
},
|
||||
{ summons: :summon },
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
:source_party,
|
||||
:remixes,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:accessory
|
||||
:guidebook1, :guidebook2, :guidebook3,
|
||||
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
|
||||
).find_by(shortcode: params[:id])
|
||||
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
# Loads party by ID for update/destroy actions
|
||||
# @return [void]
|
||||
# Loads the party by its id.
|
||||
def set
|
||||
@party = Party.where('id = ?', params[:id]).first
|
||||
end
|
||||
|
||||
# == Parameter Sanitization
|
||||
|
||||
# Sanitizes and permits party parameters
|
||||
# @return [Hash, nil] permitted parameters
|
||||
# Sanitizes and permits party parameters.
|
||||
def party_params
|
||||
return unless params[:party].present?
|
||||
|
||||
params.require(:party).permit(
|
||||
:user_id,
|
||||
:local_id,
|
||||
:edit_key,
|
||||
:extra,
|
||||
:name,
|
||||
:description,
|
||||
:raid_id,
|
||||
:job_id,
|
||||
:visibility,
|
||||
:accessory_id,
|
||||
:skill0_id,
|
||||
:skill1_id,
|
||||
:skill2_id,
|
||||
:skill3_id,
|
||||
:full_auto,
|
||||
:auto_guard,
|
||||
:auto_summon,
|
||||
:charge_attack,
|
||||
:clear_time,
|
||||
:button_count,
|
||||
:turn_count,
|
||||
:chain_count,
|
||||
:guidebook1_id,
|
||||
:guidebook2_id,
|
||||
:guidebook3_id,
|
||||
characters_attributes: [:id, :party_id, :character_id, :position,
|
||||
:uncap_level, :transcendence_step, :perpetuity,
|
||||
:awakening_id, :awakening_level,
|
||||
{ ring1: %i[modifier strength], ring2: %i[modifier strength],
|
||||
ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
|
||||
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
|
||||
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
|
||||
:turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id,
|
||||
characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
|
||||
:transcendence_step, :perpetuity, :awakening_id, :awakening_level,
|
||||
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||
earring: %i[modifier strength] }],
|
||||
summons_attributes: %i[id party_id summon_id position main friend
|
||||
quick_summon uncap_level transcendence_step],
|
||||
weapons_attributes: %i[id party_id weapon_id
|
||||
position mainhand uncap_level transcendence_step element
|
||||
weapon_key1_id weapon_key2_id weapon_key3_id
|
||||
ax_modifier1 ax_modifier2 ax_strength1 ax_strength2
|
||||
awakening_id awakening_level]
|
||||
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
|
||||
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ module Api
|
|||
class ForbiddenError < StandardError; end
|
||||
|
||||
before_action :set, except: %w[create check_email check_username]
|
||||
before_action :set_by_id, only: %w[info update]
|
||||
before_action :set_by_id, only: %w[update]
|
||||
|
||||
MAX_CHARACTERS = 5
|
||||
MAX_SUMMONS = 8
|
||||
|
|
@ -55,34 +55,39 @@ module Api
|
|||
if @user.nil?
|
||||
render_not_found_response('user')
|
||||
else
|
||||
conditions = build_conditions
|
||||
conditions[:user_id] = @user.id
|
||||
|
||||
favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited"
|
||||
parties = Party.where(conditions)
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
.includes(:favorites)
|
||||
.select(Party.arel_table[Arel.star])
|
||||
.select(
|
||||
Arel.sql(favorites_query)
|
||||
)
|
||||
.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
|
||||
count = Party.where(conditions).count
|
||||
|
||||
base_query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
# Restrict to parties belonging to the profile owner
|
||||
base_query = base_query.where(user_id: @user.id)
|
||||
skip_privacy = (current_user&.id == @user.id)
|
||||
query = PartyQueryBuilder.new(
|
||||
base_query,
|
||||
params: params,
|
||||
current_user: current_user,
|
||||
options: { skip_privacy: skip_privacy }
|
||||
).build
|
||||
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE)
|
||||
count = query.count
|
||||
render json: UserBlueprint.render(@user,
|
||||
view: :profile,
|
||||
root: 'profile',
|
||||
parties: parties,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
})
|
||||
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -98,6 +103,36 @@ module Api
|
|||
|
||||
private
|
||||
|
||||
def build_profile_query(profile_user)
|
||||
query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
# Restrict to parties belonging to the profile’s owner.
|
||||
query = query.where(user_id: profile_user.id)
|
||||
# Then apply the additional filters that we normally use:
|
||||
query = query.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
# And if there are any request-supplied filters, includes, or excludes:
|
||||
query = apply_filters(query) if params[:filters].present?
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
query.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def build_conditions
|
||||
params = request.params
|
||||
|
||||
|
|
|
|||
35
app/controllers/concerns/party_authorization_concern.rb
Normal file
35
app/controllers/concerns/party_authorization_concern.rb
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PartyAuthorizationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Checks whether the current user (or provided edit key) is authorized to modify @party.
|
||||
def authorize_party!
|
||||
if @party.user.present?
|
||||
render_unauthorized_response unless current_user.present? && @party.user == current_user
|
||||
else
|
||||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||
render_unauthorized_response unless valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the party does not belong to the current user.
|
||||
def not_owner?
|
||||
if @party.user
|
||||
return true if current_user && @party.user != current_user
|
||||
return true if current_user.nil? && edit_key.present?
|
||||
else
|
||||
return true if current_user.present?
|
||||
return true if current_user.nil? && (@party.edit_key != edit_key)
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
# Verifies that the provided edit key matches the party's edit key.
|
||||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||
provided_edit_key.present? &&
|
||||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||
end
|
||||
end
|
||||
32
app/controllers/concerns/party_preview_concern.rb
Normal file
32
app/controllers/concerns/party_preview_concern.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PartyPreviewConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Schedules preview generation for this party.
|
||||
def schedule_preview_generation
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
# Handles serving the party preview image.
|
||||
def party_preview(party)
|
||||
coordinator = PreviewService::Coordinator.new(party)
|
||||
if coordinator.generation_in_progress?
|
||||
response.headers['Retry-After'] = '2'
|
||||
default_path = Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png")
|
||||
send_file default_path, type: 'image/png', disposition: 'inline'
|
||||
return
|
||||
end
|
||||
begin
|
||||
if Rails.env.production?
|
||||
s3_object = coordinator.get_s3_object
|
||||
send_data s3_object.body.read, filename: "#{party.shortcode}.png", type: 'image/png', disposition: 'inline'
|
||||
else
|
||||
send_file coordinator.local_preview_path, type: 'image/png', disposition: 'inline'
|
||||
end
|
||||
rescue Aws::S3::Errors::NoSuchKey
|
||||
coordinator.schedule_generation unless coordinator.generation_in_progress?
|
||||
send_file Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png"), type: 'image/png', disposition: 'inline'
|
||||
end
|
||||
end
|
||||
end
|
||||
62
app/controllers/concerns/party_querying_concern.rb
Normal file
62
app/controllers/concerns/party_querying_concern.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PartyQueryingConcern
|
||||
extend ActiveSupport::Concern
|
||||
include PartyConstants
|
||||
|
||||
# Returns the common base query for Parties including all necessary associations.
|
||||
def build_common_base_query
|
||||
Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
end
|
||||
|
||||
# Uses PartyQueryBuilder to apply additional filters (includes, excludes, date ranges, etc.)
|
||||
def build_filtered_query(base_query)
|
||||
PartyQueryBuilder.new(base_query,
|
||||
params: params,
|
||||
current_user: current_user,
|
||||
options: { apply_defaults: true }).build
|
||||
end
|
||||
|
||||
# Renders paginated parties using PartyBlueprint.
|
||||
def render_paginated_parties(parties)
|
||||
render json: Api::V1::PartyBlueprint.render(
|
||||
parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: parties.total_entries,
|
||||
total_pages: parties.total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
},
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
|
||||
# Returns a remixed party name based on the current party name and current_user language.
|
||||
def remixed_name(name)
|
||||
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
|
||||
if current_user
|
||||
case current_user.language
|
||||
when 'en' then "Remix of #{blanked_name[:en]}"
|
||||
when 'ja' then "#{blanked_name[:ja]}のリミックス"
|
||||
else "Remix of #{blanked_name[:en]}"
|
||||
end
|
||||
else
|
||||
"Remix of #{blanked_name[:en]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/errors/api/v1/no_character_provided_error.rb
Normal file
26
app/errors/api/v1/no_character_provided_error.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class NoCharacterProvidedError < GranblueError
|
||||
def http_status
|
||||
422
|
||||
end
|
||||
|
||||
def code
|
||||
'no_character_provided'
|
||||
end
|
||||
|
||||
def message
|
||||
'A valid character must be provided'
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/errors/api/v1/no_summon_provided_error.rb
Normal file
26
app/errors/api/v1/no_summon_provided_error.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class NoSummonProvidedError < GranblueError
|
||||
def http_status
|
||||
422
|
||||
end
|
||||
|
||||
def code
|
||||
'no_summon_provided'
|
||||
end
|
||||
|
||||
def message
|
||||
'A valid summon must be provided'
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/errors/api/v1/no_weapon_provided_error.rb
Normal file
26
app/errors/api/v1/no_weapon_provided_error.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class NoWeaponProvidedError < GranblueError
|
||||
def http_status
|
||||
422
|
||||
end
|
||||
|
||||
def code
|
||||
'no_weapon_provided'
|
||||
end
|
||||
|
||||
def message
|
||||
'A valid weapon must be provided'
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
15
app/helpers/party_constants.rb
Normal file
15
app/helpers/party_constants.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
#
|
||||
# This module contains shared constants used for querying and filtering Party resources.
|
||||
# It is included by controllers and concerns that require these configuration values.
|
||||
#
|
||||
module PartyConstants
|
||||
COLLECTION_PER_PAGE = 15
|
||||
DEFAULT_MIN_CHARACTERS = 3
|
||||
DEFAULT_MIN_SUMMONS = 2
|
||||
DEFAULT_MIN_WEAPONS = 5
|
||||
MAX_CHARACTERS = 5
|
||||
MAX_SUMMONS = 8
|
||||
MAX_WEAPONS = 13
|
||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||
end
|
||||
|
|
@ -48,73 +48,4 @@ class Character < ApplicationRecord
|
|||
def display_resource(character)
|
||||
character.name_en
|
||||
end
|
||||
|
||||
# enum rarities: {
|
||||
# R: 1,
|
||||
# SR: 2,
|
||||
# SSR: 3
|
||||
# }
|
||||
|
||||
# enum elements: {
|
||||
# Null: 0,
|
||||
# Wind: 1,
|
||||
# Fire: 2,
|
||||
# Water: 3,
|
||||
# Earth: 4,
|
||||
# Dark: 5,
|
||||
# Light: 6
|
||||
# }
|
||||
|
||||
# enum proficiency1s: {
|
||||
# Sabre: 1,
|
||||
# Dagger: 2,
|
||||
# Axe: 3,
|
||||
# Spear: 4,
|
||||
# Bow: 5,
|
||||
# Staff: 6,
|
||||
# Melee: 7,
|
||||
# Harp: 8,
|
||||
# Gun: 9,
|
||||
# Katana: 10
|
||||
# }, _prefix: "proficiency1"
|
||||
|
||||
# enum proficiency2s: {
|
||||
# None: 0,
|
||||
# Sabre: 1,
|
||||
# Dagger: 2,
|
||||
# Axe: 3,
|
||||
# Spear: 4,
|
||||
# Bow: 5,
|
||||
# Staff: 6,
|
||||
# Melee: 7,
|
||||
# Harp: 8,
|
||||
# Gun: 9,
|
||||
# Katana: 10,
|
||||
# }, _default: :None, _prefix: "proficiency2"
|
||||
|
||||
# enum race1s: {
|
||||
# Unknown: 0,
|
||||
# Human: 1,
|
||||
# Erune: 2,
|
||||
# Draph: 3,
|
||||
# Harvin: 4,
|
||||
# Primal: 5
|
||||
# }, _prefix: "race1"
|
||||
|
||||
# enum race2s: {
|
||||
# Unknown: 0,
|
||||
# Human: 1,
|
||||
# Erune: 2,
|
||||
# Draph: 3,
|
||||
# Harvin: 4,
|
||||
# Primal: 5,
|
||||
# None: 6
|
||||
# }, _default: :None, _prefix: "race2"
|
||||
|
||||
# enum gender: {
|
||||
# Unknown: 0,
|
||||
# Male: 1,
|
||||
# Female: 2,
|
||||
# "Male/Female": 3
|
||||
# }
|
||||
end
|
||||
|
|
|
|||
34
app/models/concerns/granblue_enums.rb
Normal file
34
app/models/concerns/granblue_enums.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
module GranblueEnums
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Define constants for shared enum mappings.
|
||||
RARITIES = { R: 1, SR: 2, SSR: 3 }.freeze
|
||||
ELEMENTS = { Null: 0, Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 }.freeze
|
||||
GENDERS = { Unknown: 0, Male: 1, Female: 2, "Male/Female": 3 }.freeze
|
||||
|
||||
# Single proficiency enum mapping used for both proficiency1 and proficiency2.
|
||||
PROFICIENCY = {
|
||||
None: 0,
|
||||
Sabre: 1,
|
||||
Dagger: 2,
|
||||
Axe: 3,
|
||||
Spear: 4,
|
||||
Bow: 5,
|
||||
Staff: 6,
|
||||
Melee: 7,
|
||||
Harp: 8,
|
||||
Gun: 9,
|
||||
Katana: 10
|
||||
}.freeze
|
||||
|
||||
# Single race enum mapping used for both race1 and race2.
|
||||
RACES = {
|
||||
Unknown: 0,
|
||||
Human: 1,
|
||||
Erune: 2,
|
||||
Draph: 3,
|
||||
Harvin: 4,
|
||||
Primal: 5,
|
||||
None: 6
|
||||
}.freeze
|
||||
end
|
||||
|
|
@ -1,24 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# This file defines the GridCharacter model which represents a character's grid configuration within a party.
|
||||
# The GridCharacter model handles validations related to awakenings, rings, mastery values, and transcendence.
|
||||
# It includes virtual attributes for processing new rings and awakening data, and utilizes the amoeba gem
|
||||
# for duplicating records with specific attribute resets.
|
||||
#
|
||||
# @note This model belongs to a Character, an optional Awakening, and a Party. It maintains associations for
|
||||
# these relationships and includes counter caches for performance optimization.
|
||||
#
|
||||
# @!attribute [r] character
|
||||
# @return [Character] the associated character record.
|
||||
# @!attribute [r] awakening
|
||||
# @return [Awakening, nil] the associated awakening record (optional).
|
||||
# @!attribute [r] party
|
||||
# @return [Party] the associated party record.
|
||||
#
|
||||
class GridCharacter < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :character, foreign_key: :character_id, primary_key: :id
|
||||
|
||||
belongs_to :awakening, optional: true
|
||||
belongs_to :party,
|
||||
counter_cache: :characters_count,
|
||||
inverse_of: :characters
|
||||
|
||||
# Validations
|
||||
validates_presence_of :party
|
||||
|
||||
# Validate that uncap_level and transcendence_step are present and numeric.
|
||||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||||
validates :transcendence_step, presence: true, numericality: { only_integer: true }
|
||||
|
||||
validate :validate_awakening_level, on: :update
|
||||
validate :transcendence, on: :update
|
||||
validate :validate_over_mastery_values, on: :update
|
||||
validate :validate_aetherial_mastery_value, on: :update
|
||||
validate :over_mastery_attack_matches_hp, on: :update
|
||||
|
||||
# Virtual attribute for the new rings structure
|
||||
# Virtual attributes
|
||||
attr_accessor :new_rings
|
||||
|
||||
# Virtual attribute for the new awakening structure
|
||||
attr_accessor :new_awakening
|
||||
|
||||
##### Amoeba configuration
|
||||
|
|
@ -31,52 +50,121 @@ class GridCharacter < ApplicationRecord
|
|||
set perpetuity: false
|
||||
end
|
||||
|
||||
# Hooks
|
||||
before_validation :apply_new_rings, if: -> { new_rings.present? }
|
||||
before_validation :apply_new_awakening, if: -> { new_awakening.present? }
|
||||
|
||||
# Add awakening before the model saves
|
||||
before_save :add_awakening
|
||||
|
||||
##
|
||||
# Validates the awakening level to ensure it falls within the allowed range.
|
||||
#
|
||||
# @note Triggered on update.
|
||||
# @return [void]
|
||||
def validate_awakening_level
|
||||
errors.add(:awakening, 'awakening level too low') if awakening_level < 1
|
||||
errors.add(:awakening, 'awakening level too high') if awakening_level > 9
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the transcendence step of the character.
|
||||
#
|
||||
# Ensures that the transcendence step is appropriate based on the character's ULB status.
|
||||
# Adds errors if:
|
||||
# - The character has a positive transcendence_step but no transcendence (ulb is false).
|
||||
# - The transcendence_step exceeds the allowed maximum.
|
||||
# - The transcendence_step is negative when character.ulb is true.
|
||||
#
|
||||
# @note Triggered on update.
|
||||
# @return [void]
|
||||
def transcendence
|
||||
errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb
|
||||
errors.add(:transcendence_step, 'transcendence step too high') if transcendence_step > 5 && character.ulb
|
||||
errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the over mastery attack value for ring1.
|
||||
#
|
||||
# Checks that if ring1's modifier is set, the strength must be one of the allowed attack values.
|
||||
# Adds an error if the value is not valid.
|
||||
#
|
||||
# @return [void]
|
||||
def over_mastery_attack
|
||||
errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength'])
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the over mastery HP value for ring2.
|
||||
#
|
||||
# If ring2's modifier is present, ensures that the strength is within the allowed HP values.
|
||||
# Adds an error if the value is not valid.
|
||||
#
|
||||
# @return [void]
|
||||
def over_mastery_hp
|
||||
return if ring2['modifier'].nil?
|
||||
|
||||
errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength'])
|
||||
end
|
||||
|
||||
def over_mastery_attack_matches_hp
|
||||
return if ring1[:modifier].nil? && ring2[:modifier].nil?
|
||||
|
||||
return if ring2[:strength] == (ring1[:strength] / 2)
|
||||
|
||||
errors.add(:over_mastery,
|
||||
'over mastery attack and hp values do not match')
|
||||
##
|
||||
# Validates over mastery values by invoking individual and cross-field validations.
|
||||
#
|
||||
# This method triggers:
|
||||
# - Validation for individual over mastery values for rings 1-4.
|
||||
# - Validation ensuring that ring1's attack and ring2's HP values are consistent.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_over_mastery_values
|
||||
validate_individual_over_mastery_values
|
||||
validate_over_mastery_attack_matches_hp
|
||||
end
|
||||
|
||||
def validate_over_mastery_values
|
||||
##
|
||||
# Validates individual over mastery values for each ring (ring1 to ring4).
|
||||
#
|
||||
# Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength
|
||||
# is within the permitted range based on over mastery rules.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_individual_over_mastery_values
|
||||
# Iterate over rings 1-4 and check each ring’s value.
|
||||
[ring1, ring2, ring3, ring4].each_with_index do |ring, index|
|
||||
next if ring['modifier'].nil?
|
||||
|
||||
modifier = over_mastery_modifiers[ring['modifier']]
|
||||
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } },
|
||||
'over_mastery')
|
||||
# Use a helper to add errors if the value is out-of-range.
|
||||
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the over mastery attack value matches the HP value appropriately.
|
||||
#
|
||||
# Converts ring1 and ring2 hashes to use indifferent access, and if either ring has a modifier set,
|
||||
# checks that ring2's strength is exactly half of ring1's strength.
|
||||
# Adds an error if the values do not match.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_over_mastery_attack_matches_hp
|
||||
# Convert ring1 and ring2 to use indifferent access so that keys (symbols or strings)
|
||||
# can be accessed uniformly.
|
||||
r1 = ring1.with_indifferent_access
|
||||
r2 = ring2.with_indifferent_access
|
||||
# Only check if either ring has a modifier set.
|
||||
if r1[:modifier].present? || r2[:modifier].present?
|
||||
# Ensure that ring2's strength equals exactly half of ring1's strength.
|
||||
unless r2[:strength].to_f == (r1[:strength].to_f / 2)
|
||||
errors.add(:over_mastery, 'over mastery attack and hp values do not match')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the aetherial mastery value for the earring.
|
||||
#
|
||||
# If the earring's modifier is present and positive, it uses a helper method to check that the strength
|
||||
# falls within the allowed range for aetherial mastery.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_aetherial_mastery_value
|
||||
return if earring['modifier'].nil?
|
||||
|
||||
|
|
@ -87,22 +175,40 @@ class GridCharacter < ApplicationRecord
|
|||
'aetherial_mastery')
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint for rendering the grid character.
|
||||
#
|
||||
# @return [GridCharacterBlueprint] the blueprint class used for grid character representation.
|
||||
def blueprint
|
||||
GridCharacterBlueprint
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Adds a default awakening to the character before saving if none is set.
|
||||
#
|
||||
# Retrieves the Awakening record with slug 'character-balanced' and assigns it.
|
||||
#
|
||||
# @return [void]
|
||||
def add_awakening
|
||||
return unless awakening.nil?
|
||||
|
||||
self.awakening = Awakening.where(slug: 'character-balanced').sole
|
||||
end
|
||||
|
||||
##
|
||||
# Applies new ring configurations from the virtual attribute +new_rings+.
|
||||
#
|
||||
# Expects +new_rings+ to be an array of hashes with keys "modifier" and "strength".
|
||||
# Pads the array with default ring hashes to ensure there are exactly four rings, then assigns them to
|
||||
# ring1, ring2, ring3, and ring4.
|
||||
#
|
||||
# @return [void]
|
||||
def apply_new_rings
|
||||
# Expect new_rings to be an array of hashes, e.g.,
|
||||
# [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}]
|
||||
default_ring = { "modifier" => nil, "strength" => nil }
|
||||
default_ring = { 'modifier' => nil, 'strength' => nil }
|
||||
rings_array = Array(new_rings).map(&:to_h)
|
||||
# Pad with defaults so there are exactly four rings
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
|
|
@ -112,11 +218,29 @@ class GridCharacter < ApplicationRecord
|
|||
self.ring4 = rings_array[3]
|
||||
end
|
||||
|
||||
##
|
||||
# Applies new awakening configuration from the virtual attribute +new_awakening+.
|
||||
#
|
||||
# Sets the +awakening_id+ and +awakening_level+ based on the provided hash.
|
||||
#
|
||||
# @return [void]
|
||||
def apply_new_awakening
|
||||
self.awakening_id = new_awakening[:id]
|
||||
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
|
||||
end
|
||||
|
||||
##
|
||||
# Checks that a given property value falls within the allowed range based on the specified mastery type.
|
||||
#
|
||||
# The +property+ parameter is expected to be a hash in the following format:
|
||||
# { ring1: { atk: 300 } }
|
||||
#
|
||||
# Depending on the +type+, it validates against either over mastery or aetherial mastery values.
|
||||
# Adds an error to the record if the value is not within the permitted range.
|
||||
#
|
||||
# @param property [Hash] the property hash containing the attribute and its value.
|
||||
# @param type [String] the type of mastery validation to perform ('over_mastery' or 'aetherial_mastery').
|
||||
# @return [void]
|
||||
def check_value(property, type)
|
||||
# Input format
|
||||
# { ring1: { atk: 300 } }
|
||||
|
|
@ -135,6 +259,10 @@ class GridCharacter < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Returns a hash mapping over mastery modifier keys to their corresponding attribute names.
|
||||
#
|
||||
# @return [Hash{Integer => String}] mapping of modifier codes to attribute names.
|
||||
def over_mastery_modifiers
|
||||
{
|
||||
1 => 'atk',
|
||||
|
|
@ -155,6 +283,10 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns a hash containing allowed values for over mastery attributes.
|
||||
#
|
||||
# @return [Hash{Symbol => Array<Integer>}] mapping of attribute names to their valid values.
|
||||
def over_mastery_values
|
||||
{
|
||||
atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
|
||||
|
|
@ -175,6 +307,9 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
# Returns a hash mapping aetherial mastery modifier keys to their corresponding attribute names.
|
||||
#
|
||||
# @return [Hash{Integer => String}] mapping of aetherial mastery modifier codes to attribute names.
|
||||
def aetherial_mastery_modifiers
|
||||
{
|
||||
1 => 'da',
|
||||
|
|
@ -190,6 +325,10 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns a hash containing allowed values for aetherial mastery attributes.
|
||||
#
|
||||
# @return [Hash{Symbol => Hash{Symbol => Integer}}] mapping of attribute names to their minimum and maximum values.
|
||||
def aetherial_mastery_values
|
||||
{
|
||||
da: {
|
||||
|
|
@ -235,10 +374,18 @@ class GridCharacter < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
##
|
||||
# Returns an array of valid attack values for over mastery validation.
|
||||
#
|
||||
# @return [Array<Integer>] list of allowed attack values.
|
||||
def atk_values
|
||||
[300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]
|
||||
end
|
||||
|
||||
##
|
||||
# Returns an array of valid HP values for over mastery validation.
|
||||
#
|
||||
# @return [Array<Integer>] list of allowed HP values.
|
||||
def hp_values
|
||||
[150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Model representing a grid summon within a party.
|
||||
#
|
||||
# A GridSummon is associated with a specific {Summon} and {Party} and is responsible for
|
||||
# enforcing rules on positions, uncap levels, and transcendence steps based on the associated summon’s flags.
|
||||
#
|
||||
# @!attribute [r] summon
|
||||
# @return [Summon] the associated summon.
|
||||
# @!attribute [r] party
|
||||
# @return [Party] the associated party.
|
||||
class GridSummon < ApplicationRecord
|
||||
belongs_to :summon, foreign_key: :summon_id, primary_key: :id
|
||||
|
||||
|
|
@ -8,14 +18,35 @@ class GridSummon < ApplicationRecord
|
|||
inverse_of: :summons
|
||||
validates_presence_of :party
|
||||
|
||||
# Validate that position is provided.
|
||||
validates :position, presence: true
|
||||
validate :compatible_with_position, on: :create
|
||||
|
||||
# Validate that uncap_level and transcendence_step are present and numeric.
|
||||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||||
validates :transcendence_step, presence: true, numericality: { only_integer: true }
|
||||
|
||||
# Custom validation to enforce maximum uncap_level based on the associated Summon’s flags.
|
||||
validate :validate_uncap_level_based_on_summon_flags
|
||||
|
||||
validate :no_conflicts, on: :create
|
||||
|
||||
##
|
||||
# Returns the blueprint for rendering the grid summon.
|
||||
#
|
||||
# @return [GridSummonBlueprint] the blueprint class for grid summons.
|
||||
def blueprint
|
||||
GridSummonBlueprint
|
||||
end
|
||||
|
||||
# Returns conflicting summons if they exist
|
||||
##
|
||||
# Returns any conflicting grid summon for the given party.
|
||||
#
|
||||
# If the associated summon has a limit, this method searches the party's grid summons to find
|
||||
# any that conflict based on the summon ID.
|
||||
#
|
||||
# @param party [Party] the party in which to check for conflicts.
|
||||
# @return [GridSummon, nil] the conflicting grid summon if found, otherwise nil.
|
||||
def conflicts(party)
|
||||
return unless summon.limit
|
||||
|
||||
|
|
@ -28,13 +59,74 @@ class GridSummon < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
# Validates whether there is a conflict with the party
|
||||
##
|
||||
# Validates the uncap_level based on the associated Summon’s flags.
|
||||
#
|
||||
# This method delegates to specific validation methods for FLB, ULB, and transcendence limits.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_uncap_level_based_on_summon_flags
|
||||
return unless summon
|
||||
|
||||
validate_flb_limit
|
||||
validate_ulb_limit
|
||||
validate_transcendence_limits
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the uncap_level does not exceed 3 if the associated Summon does not have the FLB flag.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_flb_limit
|
||||
return unless !summon.flb && uncap_level.to_i > 3
|
||||
|
||||
errors.add(:uncap_level, 'cannot be greater than 3 if summon does not have FLB')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the uncap_level does not exceed 4 if the associated Summon does not have the ULB flag.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_ulb_limit
|
||||
return unless !summon.ulb && uncap_level.to_i > 4
|
||||
|
||||
errors.add(:uncap_level, 'cannot be greater than 4 if summon does not have ULB')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates the uncap_level and transcendence_step based on whether the associated Summon supports transcendence.
|
||||
#
|
||||
# If the summon does not support transcendence, the uncap_level must not exceed 5 and the transcendence_step must be 0.
|
||||
#
|
||||
# @return [void]
|
||||
def validate_transcendence_limits
|
||||
return if summon.transcendence
|
||||
|
||||
errors.add(:uncap_level, 'cannot be greater than 5 if summon does not have transcendence') if uncap_level.to_i > 5
|
||||
|
||||
return unless transcendence_step.to_i.positive?
|
||||
|
||||
errors.add(:transcendence_step, 'must be 0 if summon does not have transcendence')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that there are no conflicting grid summons in the party.
|
||||
#
|
||||
# If a conflict is found (i.e. another grid summon exists that conflicts with this one),
|
||||
# an error is added to the :series attribute.
|
||||
#
|
||||
# @return [void]
|
||||
def no_conflicts
|
||||
# Check if the grid summon conflicts with any of the other grid summons in the party
|
||||
errors.add(:series, 'must not conflict with existing summons') unless conflicts(party).nil?
|
||||
end
|
||||
|
||||
# Validates whether the summon can be added to the desired position
|
||||
##
|
||||
# Validates whether the grid summon can be added to the desired position.
|
||||
#
|
||||
# For positions 4 and 5, the associated summon must have subaura; otherwise, an error is added.
|
||||
#
|
||||
# @return [void]
|
||||
def compatible_with_position
|
||||
return unless [4, 5].include?(position.to_i) && !summon.subaura
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Model representing a grid weapon within a party.
|
||||
#
|
||||
# This model associates a weapon with a party and manages validations for weapon compatibility,
|
||||
# conflict detection, and attribute adjustments such as determining if a weapon is mainhand.
|
||||
#
|
||||
# @!attribute [r] weapon
|
||||
# @return [Weapon] the associated weapon.
|
||||
# @!attribute [r] party
|
||||
# @return [Party] the party to which the grid weapon belongs.
|
||||
# @!attribute [r] weapon_key1
|
||||
# @return [WeaponKey, nil] the primary weapon key, if assigned.
|
||||
# @!attribute [r] weapon_key2
|
||||
# @return [WeaponKey, nil] the secondary weapon key, if assigned.
|
||||
# @!attribute [r] weapon_key3
|
||||
# @return [WeaponKey, nil] the tertiary weapon key, if assigned.
|
||||
# @!attribute [r] weapon_key4
|
||||
# @return [WeaponKey, nil] the quaternary weapon key, if assigned.
|
||||
# @!attribute [r] awakening
|
||||
# @return [Awakening, nil] the associated awakening, if any.
|
||||
class GridWeapon < ApplicationRecord
|
||||
# Allowed extra positions and allowed weapon series when in an extra position.
|
||||
EXTRA_POSITIONS = [9, 10, 11].freeze
|
||||
ALLOWED_EXTRA_SERIES = [11, 16, 17, 28, 29, 32, 34].freeze
|
||||
|
||||
belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id
|
||||
|
||||
belongs_to :party,
|
||||
|
|
@ -15,10 +39,14 @@ class GridWeapon < ApplicationRecord
|
|||
|
||||
belongs_to :awakening, optional: true
|
||||
|
||||
# Validate that uncap_level and transcendence_step are present and numeric.
|
||||
validates :uncap_level, presence: true, numericality: { only_integer: true }
|
||||
validates :transcendence_step, presence: true, numericality: { only_integer: true }
|
||||
|
||||
validate :compatible_with_position, on: :create
|
||||
validate :no_conflicts, on: :create
|
||||
|
||||
before_save :mainhand?
|
||||
before_save :assign_mainhand
|
||||
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
|
|
@ -28,69 +56,99 @@ class GridWeapon < ApplicationRecord
|
|||
nullify :ax_strength2
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
##
|
||||
# Returns the blueprint for rendering the grid weapon.
|
||||
#
|
||||
# @return [GridWeaponBlueprint] the blueprint class for grid weapons.
|
||||
def blueprint
|
||||
GridWeaponBlueprint
|
||||
end
|
||||
|
||||
##
|
||||
# Returns an array of assigned weapon keys.
|
||||
#
|
||||
# This method returns an array containing weapon_key1, weapon_key2, and weapon_key3,
|
||||
# omitting any nil values.
|
||||
#
|
||||
# @return [Array<WeaponKey>] the non-nil weapon keys.
|
||||
def weapon_keys
|
||||
[weapon_key1, weapon_key2, weapon_key3].compact
|
||||
end
|
||||
|
||||
# Returns conflicting weapons if they exist
|
||||
##
|
||||
# Returns conflicting grid weapons within a given party.
|
||||
#
|
||||
# Checks if the associated weapon is present, responds to a :limit method, and is limited.
|
||||
# It then iterates over the party's grid weapons and selects those that conflict with this one,
|
||||
# based on series matching or specific conditions related to opus or draconic status.
|
||||
#
|
||||
# @param party [Party] the party in which to check for conflicts.
|
||||
# @return [ActiveRecord::Relation<GridWeapon>] an array of conflicting grid weapons (empty if none are found).
|
||||
def conflicts(party)
|
||||
return unless weapon.limit
|
||||
return [] unless weapon.present? && weapon.respond_to?(:limit) && weapon.limit
|
||||
|
||||
conflicting_weapons = []
|
||||
|
||||
party.weapons.each do |party_weapon|
|
||||
next unless party_weapon.id
|
||||
party.weapons.select do |party_weapon|
|
||||
# Skip if the record is not persisted.
|
||||
next false unless party_weapon.id.present?
|
||||
|
||||
id_match = weapon.id == party_weapon.id
|
||||
series_match = weapon.series == party_weapon.weapon.series
|
||||
both_opus_or_draconic = weapon.opus_or_draconic? && party_weapon.weapon.opus_or_draconic?
|
||||
both_draconic = weapon.draconic_or_providence? && party_weapon.weapon.draconic_or_providence?
|
||||
|
||||
conflicting_weapons << party_weapon if (series_match || both_opus_or_draconic || both_draconic) && !id_match
|
||||
(series_match || both_opus_or_draconic || both_draconic) && !id_match
|
||||
end
|
||||
|
||||
conflicting_weapons
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Conflict management methods
|
||||
|
||||
# Validates whether the weapon can be added to the desired position
|
||||
##
|
||||
# Validates whether the grid weapon is compatible with the desired position.
|
||||
#
|
||||
# For positions 9, 10, or 11 (considered extra positions), the weapon's series must belong to the allowed set.
|
||||
# If the weapon is in an extra position but does not match an allowed series, an error is added.
|
||||
#
|
||||
# @return [void]
|
||||
def compatible_with_position
|
||||
is_extra_position = [9, 10, 11].include?(position.to_i)
|
||||
is_extra_weapon = [11, 16, 17, 28, 29, 32, 34].include?(weapon.series.to_i)
|
||||
return unless weapon.present?
|
||||
|
||||
return unless is_extra_position
|
||||
|
||||
return true if is_extra_weapon
|
||||
|
||||
errors.add(:series, 'must be compatible with position')
|
||||
false
|
||||
if EXTRA_POSITIONS.include?(position.to_i) && !ALLOWED_EXTRA_SERIES.include?(weapon.series.to_i)
|
||||
errors.add(:series, 'must be compatible with position')
|
||||
end
|
||||
end
|
||||
|
||||
# Validates whether the desired weapon key can be added to the weapon
|
||||
##
|
||||
# Validates that the assigned weapon keys are compatible with the weapon.
|
||||
#
|
||||
# Iterates over each non-nil weapon key and checks compatibility using the weapon's
|
||||
# `compatible_with_key?` method. An error is added for any key that is not compatible.
|
||||
#
|
||||
# @return [void]
|
||||
def compatible_with_key
|
||||
weapon_keys.each do |key|
|
||||
errors.add(:weapon_keys, 'must be compatible with weapon') unless weapon.compatible_with_key?(key)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates whether there is a conflict with the party
|
||||
##
|
||||
# Validates that there are no conflicting grid weapons in the party.
|
||||
#
|
||||
# Checks if the current grid weapon conflicts with any other grid weapons within the party.
|
||||
# If conflicting weapons are found, an error is added.
|
||||
#
|
||||
# @return [void]
|
||||
def no_conflicts
|
||||
# Check if the grid weapon conflicts with any of the other grid weapons in the party
|
||||
return unless !conflicts(party).nil? && !conflicts(party).empty?
|
||||
|
||||
errors.add(:series, 'must not conflict with existing weapons')
|
||||
conflicting = conflicts(party)
|
||||
errors.add(:series, 'must not conflict with existing weapons') if conflicting.any?
|
||||
end
|
||||
|
||||
# Checks if the weapon should be a mainhand before saving the model
|
||||
def mainhand?
|
||||
self.mainhand = position == -1
|
||||
##
|
||||
# Determines if the grid weapon should be marked as mainhand based on its position.
|
||||
#
|
||||
# If the grid weapon's position is -1, sets the `mainhand` attribute to true.
|
||||
#
|
||||
# @return [void]
|
||||
def assign_mainhand
|
||||
self.mainhand = (position == -1)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class Job < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
|
||||
belongs_to :party
|
||||
belongs_to :party, optional: true
|
||||
has_many :skills, class_name: 'JobSkill'
|
||||
|
||||
multisearchable against: %i[name_en name_jp],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,87 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# This file defines the Party model which represents a party in the application.
|
||||
# It encapsulates the logic for managing party records including associations with
|
||||
# characters, weapons, summons, and other related models. The Party model handles
|
||||
# validations, nested attributes, preview generation, and various business logic
|
||||
# to ensure consistency and integrity of party data.
|
||||
#
|
||||
# @note The model uses ActiveRecord associations, enums, and custom validations.
|
||||
#
|
||||
# @!attribute [rw] preview_state
|
||||
# @return [Integer] the current state of the preview, represented as an enum:
|
||||
# - 0: pending
|
||||
# - 1: queued
|
||||
# - 2: in_progress
|
||||
# - 3: generated
|
||||
# - 4: failed
|
||||
# @!attribute [rw] element
|
||||
# @return [Integer] the elemental type associated with the party.
|
||||
# @!attribute [rw] clear_time
|
||||
# @return [Integer] the clear time for the party.
|
||||
# @!attribute [rw] master_level
|
||||
# @return [Integer, nil] the master level of the party.
|
||||
# @!attribute [rw] button_count
|
||||
# @return [Integer, nil] the button count, if applicable.
|
||||
# @!attribute [rw] chain_count
|
||||
# @return [Integer, nil] the chain count, if applicable.
|
||||
# @!attribute [rw] turn_count
|
||||
# @return [Integer, nil] the turn count, if applicable.
|
||||
# @!attribute [rw] ultimate_mastery
|
||||
# @return [Integer, nil] the ultimate mastery level, if applicable.
|
||||
# @!attribute [rw] visibility
|
||||
# @return [Integer] the visibility of the party:
|
||||
# - 1: Public
|
||||
# - 2: Unlisted
|
||||
# - 3: Private
|
||||
# @!attribute [rw] shortcode
|
||||
# @return [String] a unique shortcode for the party.
|
||||
# @!attribute [rw] edit_key
|
||||
# @return [String] an edit key for parties without an associated user.
|
||||
#
|
||||
# @!attribute [r] source_party
|
||||
# @return [Party, nil] the original party if this is a remix.
|
||||
# @!attribute [r] remixes
|
||||
# @return [Array<Party>] a collection of parties remixed from this party.
|
||||
# @!attribute [r] user
|
||||
# @return [User, nil] the user who created the party.
|
||||
# @!attribute [r] raid
|
||||
# @return [Raid, nil] the associated raid.
|
||||
# @!attribute [r] job
|
||||
# @return [Job, nil] the associated job.
|
||||
# @!attribute [r] accessory
|
||||
# @return [JobAccessory, nil] the accessory used in the party.
|
||||
# @!attribute [r] skill0
|
||||
# @return [JobSkill, nil] the primary skill.
|
||||
# @!attribute [r] skill1
|
||||
# @return [JobSkill, nil] the secondary skill.
|
||||
# @!attribute [r] skill2
|
||||
# @return [JobSkill, nil] the tertiary skill.
|
||||
# @!attribute [r] skill3
|
||||
# @return [JobSkill, nil] the quaternary skill.
|
||||
# @!attribute [r] guidebook1
|
||||
# @return [Guidebook, nil] the first guidebook.
|
||||
# @!attribute [r] guidebook2
|
||||
# @return [Guidebook, nil] the second guidebook.
|
||||
# @!attribute [r] guidebook3
|
||||
# @return [Guidebook, nil] the third guidebook.
|
||||
# @!attribute [r] characters
|
||||
# @return [Array<GridCharacter>] the characters associated with this party.
|
||||
# @!attribute [r] weapons
|
||||
# @return [Array<GridWeapon>] the weapons associated with this party.
|
||||
# @!attribute [r] summons
|
||||
# @return [Array<GridSummon>] the summons associated with this party.
|
||||
# @!attribute [r] favorites
|
||||
# @return [Array<Favorite>] the favorites that include this party.
|
||||
class Party < ApplicationRecord
|
||||
##### ActiveRecord Associations
|
||||
include GranblueEnums
|
||||
|
||||
# Define preview_state as an enum.
|
||||
attribute :preview_state, :integer
|
||||
enum :preview_state, { pending: 0, queued: 1, in_progress: 2, generated: 3, failed: 4 }
|
||||
|
||||
# ActiveRecord Associations
|
||||
belongs_to :source_party,
|
||||
class_name: 'Party',
|
||||
foreign_key: :source_party_id,
|
||||
|
|
@ -87,7 +167,10 @@ class Party < ApplicationRecord
|
|||
before_create :set_shortcode
|
||||
before_create :set_edit_key
|
||||
|
||||
##### Amoeba configuration
|
||||
after_commit :update_element!, on: %i[create update]
|
||||
after_commit :update_extra!, on: %i[create update]
|
||||
|
||||
# Amoeba configuration
|
||||
amoeba do
|
||||
set weapons_count: 0
|
||||
set characters_count: 0
|
||||
|
|
@ -102,45 +185,86 @@ class Party < ApplicationRecord
|
|||
include_association :summons
|
||||
end
|
||||
|
||||
##### ActiveRecord Validations
|
||||
# ActiveRecord Validations
|
||||
validate :skills_are_unique
|
||||
validate :guidebooks_are_unique
|
||||
|
||||
self.enum :preview_state, {
|
||||
pending: 0,
|
||||
queued: 1,
|
||||
in_progress: 2,
|
||||
generated: 3,
|
||||
failed: 4
|
||||
}
|
||||
# For element, validate numericality and inclusion using the allowed values from GranblueEnums.
|
||||
validates :element,
|
||||
numericality: { only_integer: true },
|
||||
inclusion: {
|
||||
in: GranblueEnums::ELEMENTS.values,
|
||||
message: "must be one of #{GranblueEnums::ELEMENTS.map { |name, value| "#{value} (#{name})" }.join(', ')}"
|
||||
},
|
||||
allow_nil: true
|
||||
|
||||
validates :clear_time, numericality: { only_integer: true }
|
||||
validates :master_level, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :button_count, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :chain_count, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :turn_count, numericality: { only_integer: true }, allow_nil: true
|
||||
validates :ultimate_mastery, numericality: { only_integer: true }, allow_nil: true
|
||||
|
||||
# Validate visibility (allowed values: 1, 2, or 3).
|
||||
validates :visibility,
|
||||
numericality: { only_integer: true },
|
||||
inclusion: {
|
||||
in: [1, 2, 3],
|
||||
message: 'must be 1 (Public), 2 (Unlisted), or 3 (Private)'
|
||||
}
|
||||
|
||||
after_commit :schedule_preview_generation, if: :should_generate_preview?
|
||||
|
||||
def is_remix
|
||||
#########################
|
||||
# Public API Methods
|
||||
#########################
|
||||
|
||||
##
|
||||
# Checks if the party is a remix of another party.
|
||||
#
|
||||
# @return [Boolean] true if the party is a remix; false otherwise.
|
||||
def remix?
|
||||
!source_party.nil?
|
||||
end
|
||||
|
||||
def remixes
|
||||
Party.where(source_party_id: id)
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint class used for rendering the party.
|
||||
#
|
||||
# @return [Class] the PartyBlueprint class.
|
||||
def blueprint
|
||||
PartyBlueprint
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party is public.
|
||||
#
|
||||
# @return [Boolean] true if the party is public; false otherwise.
|
||||
def public?
|
||||
visibility == 1
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party is unlisted.
|
||||
#
|
||||
# @return [Boolean] true if the party is unlisted; false otherwise.
|
||||
def unlisted?
|
||||
visibility == 2
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party is private.
|
||||
#
|
||||
# @return [Boolean] true if the party is private; false otherwise.
|
||||
def private?
|
||||
visibility == 3
|
||||
end
|
||||
|
||||
def is_favorited(user)
|
||||
##
|
||||
# Checks if the party is favorited by a given user.
|
||||
#
|
||||
# @param user [User, nil] the user to check for favoritism.
|
||||
# @return [Boolean] true if the party is favorited by the user; false otherwise.
|
||||
def favorited?(user)
|
||||
return false unless user
|
||||
|
||||
Rails.cache.fetch("party_#{id}_favorited_by_#{user.id}", expires_in: 1.hour) do
|
||||
|
|
@ -148,99 +272,216 @@ class Party < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the party meets the minimum requirements for preview generation.
|
||||
#
|
||||
# The party must have at least one weapon, one character, and one summon.
|
||||
#
|
||||
# @return [Boolean] true if the party is ready for preview; false otherwise.
|
||||
def ready_for_preview?
|
||||
return false if weapons_count < 1 # At least 1 weapon
|
||||
return false if characters_count < 1 # At least 1 character
|
||||
return false if summons_count < 1 # At least 1 summon
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
##
|
||||
# Determines whether a new preview should be generated for the party.
|
||||
#
|
||||
# The method checks various conditions such as preview state, expiration, and content changes.
|
||||
#
|
||||
# @return [Boolean] true if a preview generation should be triggered; false otherwise.
|
||||
def should_generate_preview?
|
||||
return false unless ready_for_preview?
|
||||
|
||||
# Always generate if no preview exists
|
||||
return true if preview_state.nil? || preview_state == 'pending'
|
||||
|
||||
# Generate if failed and enough time has passed for conditions to change
|
||||
return true if preview_state == 'failed' && preview_generated_at < 5.minutes.ago
|
||||
|
||||
# Generate if preview is old
|
||||
return true if preview_state == 'generated' && preview_expired?
|
||||
|
||||
# Only regenerate on content changes if the last generation was > 5 minutes ago
|
||||
# This prevents rapid regeneration during party building
|
||||
if preview_content_changed?
|
||||
return true if preview_generated_at.nil? || preview_generated_at < 5.minutes.ago
|
||||
end
|
||||
return true if preview_pending?
|
||||
return true if preview_failed_and_stale?
|
||||
return true if preview_generated_and_expired?
|
||||
return true if preview_content_changed_and_stale?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
##
|
||||
# Checks whether the current preview has expired based on a predefined expiry period.
|
||||
#
|
||||
# @return [Boolean] true if the preview is expired; false otherwise.
|
||||
def preview_expired?
|
||||
preview_generated_at.nil? ||
|
||||
preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago
|
||||
end
|
||||
|
||||
##
|
||||
# Determines if the content relevant for preview generation has changed.
|
||||
#
|
||||
# @return [Boolean] true if any preview-relevant attributes have changed; false otherwise.
|
||||
def preview_content_changed?
|
||||
saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) }
|
||||
end
|
||||
|
||||
##
|
||||
# Schedules the generation of a party preview if applicable.
|
||||
#
|
||||
# This method updates the preview state to 'queued' and enqueues a background job
|
||||
# to generate the preview.
|
||||
#
|
||||
# @return [void]
|
||||
def schedule_preview_generation
|
||||
return if preview_state == 'queued' || preview_state == 'in_progress'
|
||||
return if %w[queued in_progress].include?(preview_state.to_s)
|
||||
|
||||
update_column(:preview_state, 'queued')
|
||||
update_column(:preview_state, self.class.preview_states[:queued])
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_shortcode
|
||||
self.shortcode = random_string
|
||||
#########################
|
||||
# Preview Generation Helpers
|
||||
#########################
|
||||
|
||||
##
|
||||
# Checks if the preview is pending.
|
||||
#
|
||||
# @return [Boolean] true if preview_state is nil or 'pending'.
|
||||
def preview_pending?
|
||||
preview_state.nil? || preview_state == 'pending'
|
||||
end
|
||||
|
||||
def set_edit_key
|
||||
return if user
|
||||
|
||||
self.edit_key = Digest::SHA1.hexdigest([Time.now, rand].join)
|
||||
##
|
||||
# Checks if the preview generation failed and the preview is stale.
|
||||
#
|
||||
# @return [Boolean] true if preview_state is 'failed' and preview_generated_at is older than 5 minutes.
|
||||
def preview_failed_and_stale?
|
||||
preview_state == 'failed' && preview_generated_at < 5.minutes.ago
|
||||
end
|
||||
|
||||
def random_string
|
||||
num_chars = 6
|
||||
o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
|
||||
(0...num_chars).map { o[rand(o.length)] }.join
|
||||
##
|
||||
# Checks if the generated preview is expired.
|
||||
#
|
||||
# @return [Boolean] true if preview_state is 'generated' and the preview is expired.
|
||||
def preview_generated_and_expired?
|
||||
preview_state == 'generated' && preview_expired?
|
||||
end
|
||||
|
||||
##
|
||||
# Checks if the preview content has changed and the preview is stale.
|
||||
#
|
||||
# @return [Boolean] true if the preview content has changed and preview_generated_at is nil or older than 5 minutes.
|
||||
def preview_content_changed_and_stale?
|
||||
preview_content_changed? && (preview_generated_at.nil? || preview_generated_at < 5.minutes.ago)
|
||||
end
|
||||
|
||||
#########################
|
||||
# Uniqueness Validation Helpers
|
||||
#########################
|
||||
|
||||
##
|
||||
# Validates uniqueness for a given set of associations.
|
||||
#
|
||||
# @param associations [Array<Object, nil>] an array of associated objects.
|
||||
# @param attribute_names [Array<Symbol>] the corresponding attribute names for each association.
|
||||
# @param error_key [Symbol] the key for a generic error.
|
||||
# @return [void]
|
||||
def validate_uniqueness_of_associations(associations, attribute_names, error_key)
|
||||
filtered = associations.compact
|
||||
return if filtered.uniq.length == filtered.length
|
||||
|
||||
associations.each_with_index do |assoc, index|
|
||||
next if assoc.nil?
|
||||
|
||||
errors.add(attribute_names[index], 'must be unique') if associations[0...index].include?(assoc)
|
||||
end
|
||||
errors.add(error_key, 'must be unique')
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the selected skills are unique.
|
||||
#
|
||||
# @return [void]
|
||||
def skills_are_unique
|
||||
skills = [skill0, skill1, skill2, skill3].compact
|
||||
|
||||
return if skills.uniq.length == skills.length
|
||||
|
||||
skills.each_with_index do |skill, index|
|
||||
next if index.zero?
|
||||
|
||||
errors.add(:"skill#{index + 1}", 'must be unique') if skills[0...index].include?(skill)
|
||||
end
|
||||
|
||||
errors.add(:job_skills, 'must be unique')
|
||||
validate_uniqueness_of_associations([skill0, skill1, skill2, skill3],
|
||||
%i[skill0 skill1 skill2 skill3],
|
||||
:job_skills)
|
||||
end
|
||||
|
||||
##
|
||||
# Validates that the selected guidebooks are unique.
|
||||
#
|
||||
# @return [void]
|
||||
def guidebooks_are_unique
|
||||
guidebooks = [guidebook1, guidebook2, guidebook3].compact
|
||||
return if guidebooks.uniq.length == guidebooks.length
|
||||
|
||||
guidebooks.each_with_index do |book, index|
|
||||
next if index.zero?
|
||||
|
||||
errors.add(:"guidebook#{index + 1}", 'must be unique') if guidebooks[0...index].include?(book)
|
||||
end
|
||||
|
||||
errors.add(:guidebooks, 'must be unique')
|
||||
validate_uniqueness_of_associations([guidebook1, guidebook2, guidebook3],
|
||||
%i[guidebook1 guidebook2 guidebook3],
|
||||
:guidebooks)
|
||||
end
|
||||
|
||||
##
|
||||
# Provides a list of attributes that are relevant for determining if the preview content has changed.
|
||||
#
|
||||
# @return [Array<String>] an array of attribute names.
|
||||
def preview_relevant_attributes
|
||||
%w[
|
||||
name job_id element weapons_count characters_count summons_count
|
||||
full_auto auto_guard charge_attack clear_time
|
||||
]
|
||||
end
|
||||
|
||||
#########################
|
||||
# Miscellaneous Helpers
|
||||
#########################
|
||||
|
||||
##
|
||||
# Updates the party's element based on its main weapon.
|
||||
#
|
||||
# Finds the main weapon (position -1) and updates the party's element if it differs.
|
||||
#
|
||||
# @return [void]
|
||||
def update_element!
|
||||
main_weapon = weapons.detect { |gw| gw.position.to_i == -1 }
|
||||
new_element = main_weapon&.weapon&.element
|
||||
update_column(:element, new_element) if new_element.present? && element != new_element
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the party's extra flag based on weapon positions.
|
||||
#
|
||||
# Sets the extra flag to true if any weapon is in an extra position, otherwise false.
|
||||
#
|
||||
# @return [void]
|
||||
def update_extra!
|
||||
new_extra = weapons.any? { |gw| GridWeapon::EXTRA_POSITIONS.include?(gw.position.to_i) }
|
||||
update_column(:extra, new_extra) if extra != new_extra
|
||||
end
|
||||
|
||||
##
|
||||
# Sets a unique shortcode for the party before creation.
|
||||
#
|
||||
# Generates a random string and assigns it to the shortcode attribute.
|
||||
#
|
||||
# @return [void]
|
||||
def set_shortcode
|
||||
self.shortcode = random_string
|
||||
end
|
||||
|
||||
##
|
||||
# Sets an edit key for the party before creation if no associated user is present.
|
||||
#
|
||||
# The edit key is generated using a SHA1 hash based on the current time and a random value.
|
||||
#
|
||||
# @return [void]
|
||||
def set_edit_key
|
||||
return if user
|
||||
|
||||
self.edit_key ||= Digest::SHA1.hexdigest([Time.now, rand].join)
|
||||
end
|
||||
|
||||
##
|
||||
# Generates a random alphanumeric string used for the party shortcode.
|
||||
#
|
||||
# @return [String] a random string of 6 characters.
|
||||
def random_string
|
||||
num_chars = 6
|
||||
o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
|
||||
(0...num_chars).map { o[rand(o.length)] }.join
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,6 +37,54 @@ class Weapon < ApplicationRecord
|
|||
has_many :weapon_awakenings
|
||||
has_many :awakenings, through: :weapon_awakenings
|
||||
|
||||
SERIES_SLUGS = {
|
||||
1 => 'seraphic',
|
||||
2 => 'grand',
|
||||
3 => 'dark-opus',
|
||||
4 => 'revenant',
|
||||
5 => 'primal',
|
||||
6 => 'beast',
|
||||
7 => 'regalia',
|
||||
8 => 'omega',
|
||||
9 => 'olden-primal',
|
||||
10 => 'hollowsky',
|
||||
11 => 'xeno',
|
||||
12 => 'rose',
|
||||
13 => 'ultima',
|
||||
14 => 'bahamut',
|
||||
15 => 'epic',
|
||||
16 => 'cosmos',
|
||||
17 => 'superlative',
|
||||
18 => 'vintage',
|
||||
19 => 'class-champion',
|
||||
20 => 'replica',
|
||||
21 => 'relic',
|
||||
22 => 'rusted',
|
||||
23 => 'sephira',
|
||||
24 => 'vyrmament',
|
||||
25 => 'upgrader',
|
||||
26 => 'astral',
|
||||
27 => 'draconic',
|
||||
28 => 'eternal-splendor',
|
||||
29 => 'ancestral',
|
||||
30 => 'new-world-foundation',
|
||||
31 => 'ennead',
|
||||
32 => 'militis',
|
||||
33 => 'malice',
|
||||
34 => 'menace',
|
||||
35 => 'illustrious',
|
||||
36 => 'proven',
|
||||
37 => 'revans',
|
||||
38 => 'world',
|
||||
39 => 'exo',
|
||||
40 => 'draconic-providence',
|
||||
41 => 'celestial',
|
||||
42 => 'omega-rebirth',
|
||||
43 => 'collab',
|
||||
98 => 'event',
|
||||
99 => 'gacha'
|
||||
}.freeze
|
||||
|
||||
def blueprint
|
||||
WeaponBlueprint
|
||||
end
|
||||
|
|
@ -51,11 +99,23 @@ class Weapon < ApplicationRecord
|
|||
|
||||
# Returns whether the weapon is included in the Draconic or Dark Opus series
|
||||
def opus_or_draconic?
|
||||
[2, 3].include?(series)
|
||||
[3, 27].include?(series)
|
||||
end
|
||||
|
||||
# Returns whether the weapon belongs to the Draconic Weapon series or the Draconic Weapon Providence series
|
||||
def draconic_or_providence?
|
||||
[3, 34].include?(series)
|
||||
[27, 40].include?(series)
|
||||
end
|
||||
|
||||
def self.element_changeable?(series)
|
||||
[4, 13, 17, 19].include?(series.to_i)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def series_slug
|
||||
# Assuming series is an array, take the first value
|
||||
series_number = series.first
|
||||
SERIES_SLUGS[series_number]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
251
app/services/dataminer.rb
Normal file
251
app/services/dataminer.rb
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Dataminer
|
||||
include HTTParty
|
||||
|
||||
BOT_UID = '39094985'
|
||||
GAME_VERSION = '1741068713'
|
||||
|
||||
base_uri 'https://game.granbluefantasy.jp'
|
||||
format :json
|
||||
|
||||
HEADERS = {
|
||||
'Accept' => 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
'Accept-Encoding' => 'gzip, deflate, br, zstd',
|
||||
'Content-Type' => 'application/json',
|
||||
'DNT' => '1',
|
||||
'Origin' => 'https://game.granbluefantasy.jp',
|
||||
'Referer' => 'https://game.granbluefantasy.jp/',
|
||||
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
|
||||
'X-Requested-With' => 'XMLHttpRequest'
|
||||
}.freeze
|
||||
|
||||
attr_reader :page, :cookies, :logger, :debug
|
||||
|
||||
def initialize(page:, access_token:, wing:, midship:, t: 'dummy', debug: false)
|
||||
@page = page
|
||||
@cookies = {
|
||||
access_gbtk: access_token,
|
||||
wing: wing,
|
||||
t: t,
|
||||
midship: midship
|
||||
}
|
||||
@debug = debug
|
||||
setup_logger
|
||||
end
|
||||
|
||||
def fetch
|
||||
timestamp = Time.now.to_i * 1000
|
||||
response = self.class.post(
|
||||
"/#{page}?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}",
|
||||
headers: HEADERS.merge(
|
||||
'Cookie' => format_cookies,
|
||||
'X-VERSION' => GAME_VERSION
|
||||
)
|
||||
)
|
||||
|
||||
raise AuthenticationError if auth_failed?(response)
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def fetch_character(granblue_id)
|
||||
timestamp = Time.now.to_i * 1000
|
||||
url = "/archive/npc_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
|
||||
body = {
|
||||
special_token: nil,
|
||||
user_id: BOT_UID,
|
||||
kind_name: '0',
|
||||
attribute: '0',
|
||||
event_id: nil,
|
||||
story_id: nil,
|
||||
style: 1,
|
||||
character_id: granblue_id
|
||||
}
|
||||
|
||||
response = fetch_detail(url, body)
|
||||
update_game_data('Character', granblue_id, response) if response
|
||||
response
|
||||
end
|
||||
|
||||
def fetch_weapon(granblue_id)
|
||||
timestamp = Time.now.to_i * 1000
|
||||
url = "/archive/weapon_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
|
||||
body = {
|
||||
special_token: nil,
|
||||
user_id: BOT_UID,
|
||||
kind_name: '0',
|
||||
attribute: '0',
|
||||
event_id: nil,
|
||||
story_id: nil,
|
||||
weapon_id: granblue_id
|
||||
}
|
||||
|
||||
response = fetch_detail(url, body)
|
||||
update_game_data('Weapon', granblue_id, response) if response
|
||||
response
|
||||
end
|
||||
|
||||
def fetch_summon(granblue_id)
|
||||
timestamp = Time.now.to_i * 1000
|
||||
url = "/archive/summon_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
|
||||
body = {
|
||||
special_token: nil,
|
||||
user_id: BOT_UID,
|
||||
kind_name: '0',
|
||||
attribute: '0',
|
||||
event_id: nil,
|
||||
story_id: nil,
|
||||
summon_id: granblue_id
|
||||
}
|
||||
|
||||
response = fetch_detail(url, body)
|
||||
update_game_data('Summon', granblue_id, response) if response
|
||||
response
|
||||
end
|
||||
|
||||
# Public batch processing methods
|
||||
def fetch_all_characters(only_missing: false)
|
||||
process_all_records('Character', only_missing: only_missing)
|
||||
end
|
||||
|
||||
def fetch_all_weapons(only_missing: false)
|
||||
process_all_records('Weapon', only_missing: only_missing)
|
||||
end
|
||||
|
||||
def fetch_all_summons(only_missing: false)
|
||||
process_all_records('Summon', only_missing: only_missing)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_cookies
|
||||
cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
|
||||
end
|
||||
|
||||
def auth_failed?(response)
|
||||
return true if response.code != 200
|
||||
|
||||
begin
|
||||
parsed = JSON.parse(response.body)
|
||||
parsed.is_a?(Hash) && parsed['auth_status'] == 'require_auth'
|
||||
rescue JSON::ParserError
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def setup_logger
|
||||
@logger = ::Logger.new($stdout)
|
||||
@logger.level = debug ? ::Logger::DEBUG : ::Logger::INFO
|
||||
@logger.formatter = proc do |severity, _datetime, _progname, msg|
|
||||
case severity
|
||||
when 'DEBUG'
|
||||
debug ? "#{msg}\n" : ''
|
||||
else
|
||||
"#{msg}\n"
|
||||
end
|
||||
end
|
||||
|
||||
# Suppress SQL logs in non-debug mode
|
||||
return if debug
|
||||
|
||||
ActiveRecord::Base.logger.level = ::Logger::INFO if defined?(ActiveRecord::Base)
|
||||
end
|
||||
|
||||
def fetch_detail(url, body)
|
||||
logger.debug "\n=== Request Details ==="
|
||||
logger.debug "URL: #{url}"
|
||||
logger.debug 'Headers:'
|
||||
logger.debug HEADERS.merge(
|
||||
'Cookie' => format_cookies,
|
||||
'X-VERSION' => GAME_VERSION
|
||||
).inspect
|
||||
logger.debug 'Body:'
|
||||
logger.debug body.to_json
|
||||
logger.debug '===================='
|
||||
|
||||
response = self.class.post(
|
||||
url,
|
||||
headers: HEADERS.merge(
|
||||
'Cookie' => format_cookies,
|
||||
'X-VERSION' => GAME_VERSION
|
||||
),
|
||||
body: body.to_json
|
||||
)
|
||||
|
||||
logger.debug "\n=== Response Details ==="
|
||||
logger.debug "Response code: #{response.code}"
|
||||
logger.debug 'Response headers:'
|
||||
logger.debug response.headers.inspect
|
||||
logger.debug 'Raw response body:'
|
||||
logger.debug response.body.inspect
|
||||
begin
|
||||
logger.debug 'Parsed response body (if JSON):'
|
||||
logger.debug JSON.parse(response.body).inspect
|
||||
rescue JSON::ParserError => e
|
||||
logger.debug "Could not parse as JSON: #{e.message}"
|
||||
end
|
||||
logger.debug '======================'
|
||||
|
||||
raise AuthenticationError if auth_failed?(response)
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def update_game_data(model_name, granblue_id, response_data)
|
||||
return unless response_data.is_a?(Hash)
|
||||
|
||||
model = Object.const_get(model_name)
|
||||
record = model.find_by(granblue_id: granblue_id)
|
||||
|
||||
if record
|
||||
record.update(game_raw_en: response_data)
|
||||
logger.debug "Updated #{model_name} #{granblue_id}"
|
||||
else
|
||||
logger.warn "#{model_name} with granblue_id #{granblue_id} not found in database"
|
||||
end
|
||||
rescue StandardError => e
|
||||
logger.error "Error updating #{model_name} #{granblue_id}: #{e.message}"
|
||||
end
|
||||
|
||||
def process_all_records(model_name, only_missing: false)
|
||||
model = Object.const_get(model_name)
|
||||
scope = model
|
||||
scope = scope.where(game_raw_en: nil) if only_missing
|
||||
|
||||
total = scope.count
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
logger.info "Starting to fetch #{total} #{model_name.downcase}s#{' (missing data only)' if only_missing}..."
|
||||
|
||||
scope.find_each do |record|
|
||||
logger.info "\nProcessing #{model_name} #{record.granblue_id} (#{success_count + error_count + 1}/#{total})"
|
||||
|
||||
response = case model_name
|
||||
when 'Character'
|
||||
fetch_character(record.granblue_id)
|
||||
when 'Weapon'
|
||||
fetch_weapon(record.granblue_id)
|
||||
when 'Summon'
|
||||
fetch_summon(record.granblue_id)
|
||||
end
|
||||
|
||||
success_count += 1
|
||||
logger.debug "Successfully processed #{model_name} #{record.granblue_id}"
|
||||
|
||||
sleep(1)
|
||||
rescue StandardError => e
|
||||
error_count += 1
|
||||
logger.error "Error processing #{model_name} #{record.granblue_id}: #{e.message}"
|
||||
end
|
||||
|
||||
logger.info "\nProcessing complete!"
|
||||
logger.info "Total: #{total}"
|
||||
logger.info "Successful: #{success_count}"
|
||||
logger.info "Failed: #{error_count}"
|
||||
end
|
||||
|
||||
class AuthenticationError < StandardError; end
|
||||
end
|
||||
274
app/services/party_query_builder.rb
Normal file
274
app/services/party_query_builder.rb
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# PartyQueryBuilder is responsible for building an ActiveRecord query for parties
|
||||
# by applying a series of filters, includes, and excludes based on request parameters.
|
||||
# It is used to build both the general parties query and specialized queries (like
|
||||
# for a user’s profile) while keeping the filtering logic DRY.
|
||||
#
|
||||
# Usage:
|
||||
# base_query = Party.includes(:user, :job, ... ) # a starting query
|
||||
# query_builder = PartyQueryBuilder.new(base_query, params: params, current_user: current_user, options: { default_status: 'active' })
|
||||
# final_query = query_builder.build
|
||||
#
|
||||
class PartyQueryBuilder
|
||||
# Initialize with a base query, a params hash, and the current user.
|
||||
# Options may include default filters like :default_status, default counts, and max values.
|
||||
def initialize(base_query, params:, current_user:, options: {})
|
||||
@base_query = base_query
|
||||
@params = params
|
||||
@current_user = current_user
|
||||
@options = options
|
||||
end
|
||||
|
||||
# Builds the final ActiveRecord query by applying filters, includes, and excludes.
|
||||
#
|
||||
# Edge cases handled:
|
||||
# - If a parameter is missing or blank, default values are used.
|
||||
# - If no recency is provided, no date range is applied.
|
||||
# - If includes/excludes parameters are missing, those methods are skipped.
|
||||
#
|
||||
# Also applies a default status filter (if provided via options) using a dedicated callback.
|
||||
def build
|
||||
query = @base_query
|
||||
query = apply_filters(query)
|
||||
query = apply_default_status(query) if @options[:default_status]
|
||||
query = apply_privacy_settings(query)
|
||||
query = apply_includes(query, @params[:includes]) if @params[:includes].present?
|
||||
query = apply_excludes(query, @params[:excludes]) if @params[:excludes].present?
|
||||
query.order(created_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Applies filtering conditions to the given query.
|
||||
# Combines generic filters (like element, raid_id, created_at) with object count ranges.
|
||||
#
|
||||
# Example edge case: If the request does not specify 'characters_count',
|
||||
# then the default (e.g. 3) will be used, with the upper bound coming from a constant.
|
||||
def apply_filters(query)
|
||||
query = apply_base_filters(query)
|
||||
query = apply_name_quality_filter(query)
|
||||
query = apply_count_filters(query)
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
# Example callback method: if no explicit status filter is provided, we may want
|
||||
# to force the query to include only records with a given default status.
|
||||
# This method encapsulates that behavior.
|
||||
def apply_default_status(query)
|
||||
query.where(status: @options[:default_status])
|
||||
end
|
||||
|
||||
# Applies privacy settings based on whether the current user is an admin.
|
||||
def apply_privacy_settings(query)
|
||||
# If the options say to skip privacy filtering (e.g. when viewing your own profile),
|
||||
# then return the query unchanged.
|
||||
return query if @options[:skip_privacy]
|
||||
|
||||
# Otherwise, if not admin, only show public parties.
|
||||
return query if @current_user&.admin?
|
||||
|
||||
query.where('visibility = ?', 1)
|
||||
end
|
||||
|
||||
# Builds a hash of filtering conditions from the params.
|
||||
#
|
||||
# Uses guard clauses to ignore keys when a parameter is missing.
|
||||
def build_filters
|
||||
{
|
||||
element: (@params[:element].present? ? @params[:element].to_i : nil),
|
||||
raid_id: @params[:raid],
|
||||
created_at: build_date_range,
|
||||
full_auto: build_option(@params[:full_auto]),
|
||||
auto_guard: build_option(@params[:auto_guard]),
|
||||
charge_attack: build_option(@params[:charge_attack])
|
||||
}.compact
|
||||
end
|
||||
|
||||
# Returns a date range based on the 'recency' parameter.
|
||||
# If recency is not provided, returns nil so no date filter is applied.
|
||||
def build_date_range
|
||||
return nil unless @params[:recency].present?
|
||||
start_time = DateTime.current - @params[:recency].to_i.seconds
|
||||
start_time.beginning_of_day..DateTime.current
|
||||
end
|
||||
|
||||
# Returns the count from the parameter or a default value if the parameter is blank.
|
||||
def build_count(value, default_value)
|
||||
value.blank? ? default_value : value.to_i
|
||||
end
|
||||
|
||||
# Processes an option parameter.
|
||||
# Returns the integer value unless the value is blank or equal to -1.
|
||||
def build_option(value)
|
||||
value.to_i unless value.blank? || value.to_i == -1
|
||||
end
|
||||
|
||||
# Applies "includes" filtering to the query based on a comma-separated string.
|
||||
# For each provided ID, it adds a condition using an EXISTS subquery.
|
||||
#
|
||||
# Edge case example: If an ID does not start with a known prefix,
|
||||
# grid_table_and_object_table returns [nil, nil] and the condition is skipped.
|
||||
def apply_includes(query, includes)
|
||||
includes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
condition = <<-SQL.squish
|
||||
EXISTS (
|
||||
SELECT 1 FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
query
|
||||
end
|
||||
|
||||
# Applies "excludes" filtering to the query based on a comma-separated string.
|
||||
# Works similarly to apply_includes, but with a NOT EXISTS clause.
|
||||
def apply_excludes(query, excludes)
|
||||
excludes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
condition = <<-SQL.squish
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ? AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
query
|
||||
end
|
||||
|
||||
# Applies base filtering conditions from build_filters to the query.
|
||||
# @param query [ActiveRecord::QueryMethods::WhereChain] The current query.
|
||||
# @return [ActiveRecord::Relation] The query with base filters applied.
|
||||
def apply_base_filters(query)
|
||||
query.where(build_filters)
|
||||
end
|
||||
|
||||
# Applies the name quality filter to the query if the parameter is present.
|
||||
# @param query [ActiveRecord::QueryMethods::WhereChain] The current query.
|
||||
# @return [ActiveRecord::Relation] The query with the name quality filter applied.
|
||||
def apply_name_quality_filter(query)
|
||||
@params[:name_quality].present? ? query.where(name_quality) : query
|
||||
end
|
||||
|
||||
# Applies count filters to the query based on provided parameters or default options.
|
||||
# If apply_defaults is set in options, default ranges are applied.
|
||||
# Otherwise, count ranges are built from provided parameters.
|
||||
# @param query [ | ActiveRecord::QueryMethods::WhereChain] The current query.
|
||||
# @return [ActiveRecord::Relation] The query with count filters applied.
|
||||
def apply_count_filters(query)
|
||||
if @options[:apply_defaults]
|
||||
query.where(
|
||||
weapons_count: default_weapons_count..max_weapons,
|
||||
characters_count: default_characters_count..max_characters,
|
||||
summons_count: default_summons_count..max_summons
|
||||
)
|
||||
elsif count_filter_provided?
|
||||
query.where(build_count_conditions)
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
# Determines if any count filter parameters have been provided.
|
||||
# @return [Boolean] True if any count filters are provided, false otherwise.
|
||||
def count_filter_provided?
|
||||
@params.key?(:weapons_count) || @params.key?(:characters_count) || @params.key?(:summons_count)
|
||||
end
|
||||
|
||||
# Builds a hash of count conditions based on the count filter parameters.
|
||||
# @return [Hash] A hash with keys :weapons_count, :characters_count, and :summons_count.
|
||||
def build_count_conditions
|
||||
{
|
||||
weapons_count: build_range(@params[:weapons_count], max_weapons),
|
||||
characters_count: build_range(@params[:characters_count], max_characters),
|
||||
summons_count: build_range(@params[:summons_count], max_summons)
|
||||
}
|
||||
end
|
||||
|
||||
# Constructs a range for a given count parameter.
|
||||
# @param param_value [String, nil] The count filter parameter value.
|
||||
# @param max_value [Integer] The maximum allowed value for the count.
|
||||
# @return [Range] A range from the provided count (or 0 if blank) to the max_value.
|
||||
def build_range(param_value, max_value)
|
||||
param_value.present? ? param_value.to_i..max_value : 0..max_value
|
||||
end
|
||||
|
||||
# Maps an ID’s first character to the corresponding grid table and object table names.
|
||||
#
|
||||
# For example:
|
||||
# '3...' => %w[grid_characters characters]
|
||||
# '2...' => %w[grid_summons summons]
|
||||
# '1...' => %w[grid_weapons weapons]
|
||||
# Returns [nil, nil] for unknown prefixes.
|
||||
def grid_table_and_object_table(id)
|
||||
case id[0]
|
||||
when '3'
|
||||
%w[grid_characters characters]
|
||||
when '2'
|
||||
%w[grid_summons summons]
|
||||
when '1'
|
||||
%w[grid_weapons weapons]
|
||||
else
|
||||
[nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
# Default values and maximum limits for counts.
|
||||
def default_weapons_count
|
||||
@options[:default_weapons_count] || 5
|
||||
end
|
||||
|
||||
def default_characters_count
|
||||
@options[:default_characters_count] || 3
|
||||
end
|
||||
|
||||
def default_summons_count
|
||||
@options[:default_summons_count] || 2
|
||||
end
|
||||
|
||||
def max_weapons
|
||||
@options[:max_weapons] || 13
|
||||
end
|
||||
|
||||
def max_characters
|
||||
@options[:max_characters] || 5
|
||||
end
|
||||
|
||||
def max_summons
|
||||
@options[:max_summons] || 8
|
||||
end
|
||||
|
||||
# Stub method for name quality filtering.
|
||||
# In your application, this might be defined in a helper or concern.
|
||||
def name_quality
|
||||
# Example: exclude parties with names like 'Untitled' (edge case)
|
||||
"name NOT LIKE 'Untitled%'"
|
||||
end
|
||||
|
||||
# Stub method for user quality filtering.
|
||||
# Adjust as needed for your actual implementation.
|
||||
def user_quality
|
||||
'user_id IS NOT NULL'
|
||||
end
|
||||
|
||||
# Stub method for original filtering.
|
||||
def original
|
||||
'source_party_id IS NULL'
|
||||
end
|
||||
|
||||
# Stub method for privacy filtering.
|
||||
# Here we assume that if the current user is not an admin, only public parties (visibility = 1) are returned.
|
||||
def privacy
|
||||
return nil if @current_user && @current_user.admin?
|
||||
|
||||
'visibility = 1'
|
||||
end
|
||||
end
|
||||
|
|
@ -7,9 +7,7 @@ module PreviewService
|
|||
PREVIEW_EXPIRY = 30.days
|
||||
GENERATION_TIMEOUT = 5.minutes
|
||||
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
|
||||
|
||||
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
|
||||
PREVIEW_EXPIRY = 30.days
|
||||
|
||||
# Public Interface - Core Operations
|
||||
|
||||
|
|
|
|||
44
app/services/processors/base_processor.rb
Normal file
44
app/services/processors/base_processor.rb
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# BaseProcessor provides shared functionality for processing transformed deck data
|
||||
# into new party records. Subclasses must implement the +process+ method.
|
||||
#
|
||||
# @abstract
|
||||
class BaseProcessor
|
||||
##
|
||||
# Initializes the processor.
|
||||
#
|
||||
# @param party [Party] the Party record to which the component will be added.
|
||||
# @param data [Object] the transformed data for this component.
|
||||
# @param options [Hash] optional additional options.
|
||||
def initialize(party, data, options = {})
|
||||
@party = party
|
||||
@data = data
|
||||
@options = options
|
||||
end
|
||||
|
||||
##
|
||||
# Process the given data and create associated records.
|
||||
#
|
||||
# @abstract Subclasses must implement this method.
|
||||
# @return [void]
|
||||
def process
|
||||
raise NotImplementedError, "#{self.class} must implement the process method"
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
attr_reader :party, :data, :options
|
||||
|
||||
##
|
||||
# Logs a message to Rails.logger.
|
||||
#
|
||||
# @param message [String] the message to log.
|
||||
# @return [void]
|
||||
def log(message)
|
||||
Rails.logger.info "[PROCESSOR][#{self.class.name}] #{message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
89
app/services/processors/character_processor.rb
Normal file
89
app/services/processors/character_processor.rb
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# CharacterProcessor processes an array of character data and creates GridCharacter records.
|
||||
#
|
||||
# @example
|
||||
# processor = Processors::CharacterProcessor.new(party, transformed_characters_array)
|
||||
# processor.process
|
||||
class CharacterProcessor < BaseProcessor
|
||||
def initialize(party, data, type = :normal, options = {})
|
||||
super(party, data, options)
|
||||
@party = party
|
||||
@data = data
|
||||
end
|
||||
|
||||
##
|
||||
# Processes character data.
|
||||
#
|
||||
# Iterates over each character hash in +data+ and creates a new GridCharacter record.
|
||||
# Expects each character hash to include keys such as :id, :position, :uncap, etc.
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
unless @data.is_a?(Hash)
|
||||
Rails.logger.error "[CHARACTER] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('npc')
|
||||
Rails.logger.error '[CHARACTER] Missing npc data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
@data = @data.with_indifferent_access
|
||||
characters_data = @data.dig('deck', 'npc')
|
||||
|
||||
grid_characters = process_characters(characters_data)
|
||||
grid_characters.each do |grid_character|
|
||||
begin
|
||||
grid_character.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "[CHARACTER] Failed to create GridCharacter: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
rescue StandardError => e
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_characters(characters_data)
|
||||
characters_data.map do |key, raw_character|
|
||||
next if raw_character.nil? || raw_character['param'].nil? || raw_character['master'].nil?
|
||||
|
||||
position = key.to_i - 1
|
||||
|
||||
# Find the Character record by its granblue_id.
|
||||
character_id = raw_character.dig('master', 'id')
|
||||
character = Character.find_by(granblue_id: character_id)
|
||||
|
||||
unless character
|
||||
Rails.logger.error "[CHARACTER] Character not found with id #{character_id}"
|
||||
next
|
||||
end
|
||||
|
||||
# The deck doesn't have Awakening data, so use the default
|
||||
awakening = Awakening.where(slug: 'character-balanced').first
|
||||
grid_character = GridCharacter.create(
|
||||
party_id: @party.id,
|
||||
character_id: character.id,
|
||||
uncap_level: raw_character.dig('param', 'evolution').to_i,
|
||||
transcendence_step: raw_character.dig('param', 'phase').to_i,
|
||||
position: position,
|
||||
perpetuity: raw_character.dig('param', 'has_npcaugment_constant'),
|
||||
awakening: awakening
|
||||
)
|
||||
|
||||
grid_character
|
||||
end.compact
|
||||
end
|
||||
|
||||
# Converts a value to a boolean.
|
||||
def parse_boolean(val)
|
||||
val.to_s.downcase == 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
127
app/services/processors/job_processor.rb
Normal file
127
app/services/processors/job_processor.rb
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# JobProcessor is responsible for processing job data from the transformed deck data.
|
||||
# It finds a Job record by the master’s id and assigns it (and its job skills) to the Party.
|
||||
#
|
||||
# @example
|
||||
# raw_data = { 'job' => { "master": { "id": '130401', ... }, ... }, 'set_action': [ ... ] }
|
||||
# processor = Processors::JobProcessor.new(party, raw_data, language: 'en')
|
||||
# processor.process
|
||||
class JobProcessor < BaseProcessor
|
||||
##
|
||||
# Initializes a new JobProcessor.
|
||||
#
|
||||
# @param party [Party] the Party record.
|
||||
# @param data [Hash] the raw JSON data.
|
||||
# @param options [Hash] options hash; e.g. expects :language.
|
||||
def initialize(party, data, options = {})
|
||||
super(party, options)
|
||||
@party = party
|
||||
@data = data
|
||||
@language = options[:language] || 'en'
|
||||
end
|
||||
|
||||
##
|
||||
# Processes job data.
|
||||
#
|
||||
# Finds a Job record using a case‐insensitive search on +name_en+ or +name_jp+.
|
||||
# If found, it assigns the job to the party and (if provided) assigns subskills.
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
if @data.is_a?(Hash)
|
||||
@data = @data.with_indifferent_access
|
||||
else
|
||||
Rails.logger.error "[JOB] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('pc') && @data['deck']['pc'].key?('job')
|
||||
Rails.logger.error '[JOB] Missing job data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
# Extract job data
|
||||
job_data = @data.dig('deck', 'pc', 'job', 'master')
|
||||
job_skills = @data.dig('deck', 'pc', 'set_action')
|
||||
job_accessory_id = @data.dig('deck', 'pc', 'familiar_id') || @data.dig('deck', 'pc', 'shield_id')
|
||||
|
||||
# Look up and set the Job and its main skill
|
||||
process_core_job(job_data)
|
||||
|
||||
# Look up and set the job skills.
|
||||
if job_skills.present?
|
||||
skills = process_job_skills(job_skills)
|
||||
party.update(skill1: skills[0], skill2: skills[1], skill3: skills[2])
|
||||
end
|
||||
|
||||
# Look up and set the job accessory.
|
||||
accessory = process_job_accessory(job_accessory_id)
|
||||
party.update(accessory: accessory)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[JOB] Exception during job processing: #{e.message}"
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Updates the party with the corresponding job and its main skill.
|
||||
#
|
||||
# This method attempts to locate a Job using the provided job_data's 'id' (which represents
|
||||
# the granblue_id). If the job is found, it retrieves the job's main
|
||||
# skill (i.e. the JobSkill record where `main` is true) and updates the party with the job
|
||||
# and its main skill. If no job is found, the method returns without updating.
|
||||
#
|
||||
# @param [Hash] job_data A hash containing job information.
|
||||
# It must include the key 'id', which holds the granblue_id for the job.
|
||||
# @return [void]
|
||||
#
|
||||
# @example
|
||||
# job_data = { 'id' => 42 }
|
||||
# process_core_job(job_data)
|
||||
def process_core_job(job_data)
|
||||
# Look up the Job by granblue_id (the job master id).
|
||||
job = Job.find_by(granblue_id: job_data['id'])
|
||||
return unless job
|
||||
|
||||
main_skill = JobSkill.find_by(job_id: job.id, main: true)
|
||||
|
||||
party.update(job: job, skill0: main_skill)
|
||||
end
|
||||
|
||||
##
|
||||
# Processes and associates job skills with a given job.
|
||||
#
|
||||
# This method first removes any existing skills from the job. It then iterates over the provided
|
||||
# array of skill names, attempting to find a matching JobSkill record by comparing the provided
|
||||
# name against both the English and Japanese name fields. Any found JobSkill records are then
|
||||
# associated with the job. Finally, the method logs the processed job skill names.
|
||||
#
|
||||
# @param job_skills [Array<String>] an array of job skill names.
|
||||
# @return [Array<JobSkill>] an array of JobSkill records that were associated with the job.
|
||||
def process_job_skills(job_skills)
|
||||
job_skills.map do |skill|
|
||||
name = skill['name']
|
||||
JobSkill.find_by(name_en: name)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes raw data to find the currently set job accessory
|
||||
#
|
||||
# Searches JobAccessories for the given `granblue_id`
|
||||
#
|
||||
# @param accessory_id [String] the granblue_id of the accessory
|
||||
def process_job_accessory(accessory_id)
|
||||
JobAccessory.find_by(granblue_id: accessory_id)
|
||||
end
|
||||
|
||||
# Converts a value (string or boolean) to a boolean.
|
||||
def to_boolean(val)
|
||||
val.to_s.downcase == 'true'
|
||||
end
|
||||
end
|
||||
end
|
||||
201
app/services/processors/summon_processor.rb
Normal file
201
app/services/processors/summon_processor.rb
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# SummonProcessor processes an array of summon data and creates GridSummon records.
|
||||
# It handles different summon types based on the +type+ parameter:
|
||||
# - :normal => standard summons
|
||||
# - :friend => friend summon (fixed position and uncap logic)
|
||||
# - :sub => sub summons (position based on order)
|
||||
#
|
||||
# @example
|
||||
# normal_processor = SummonProcessor.new(party, summons_array, :normal, quick_summon_id)
|
||||
# normal_processor.process
|
||||
#
|
||||
# friend_processor = SummonProcessor.new(party, [friend_summon_name], :friend)
|
||||
# friend_processor.process
|
||||
class SummonProcessor < BaseProcessor
|
||||
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
|
||||
|
||||
##
|
||||
# Initializes a new SummonProcessor.
|
||||
#
|
||||
# @param party [Party] the Party record.
|
||||
# @param data [Hash] the deck hash.
|
||||
# @param type [Symbol] the type of summon (:normal, :friend, or :sub).
|
||||
# @param quick_summon_id [String, nil] (optional) the quick summon identifier.
|
||||
# @param options [Hash] additional options.
|
||||
def initialize(party, data, type = :normal, options = {})
|
||||
super(party, data, options)
|
||||
@party = party
|
||||
@data = data
|
||||
@type = type
|
||||
end
|
||||
|
||||
##
|
||||
# Processes summon data and creates GridSummon records.
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
unless @data.is_a?(Hash)
|
||||
Rails.logger.error "[SUMMON] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('pc')
|
||||
Rails.logger.error '[SUMMON] Missing npc data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
@data = @data.with_indifferent_access
|
||||
summons_data = @data.dig('deck', 'pc', 'summons')
|
||||
sub_summons_data = @data.dig('deck', 'pc', 'sub_summons')
|
||||
|
||||
grid_summons = process_summons(summons_data, sub: false)
|
||||
friend_summon = process_friend_summon
|
||||
sub_summons = process_summons(sub_summons_data, sub: true)
|
||||
|
||||
summons = [*grid_summons, friend_summon, *sub_summons]
|
||||
|
||||
summons.each do |summon|
|
||||
summon.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "[SUMMON] Failed to create GridSummon: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :type
|
||||
|
||||
##
|
||||
# Processes a set of summon data and creates GridSummon records.
|
||||
#
|
||||
# @param summons [Hash] the summon data
|
||||
# @param sub [Boolean] true if we are polling sub summons
|
||||
# @return [Array<GridSummon>]
|
||||
def process_summons(summons, sub: false)
|
||||
internal_quick_summon_id = @data['quick_user_summon_id'].to_i if sub
|
||||
|
||||
summons.map do |key, raw_summon|
|
||||
summon_params = raw_summon['param']
|
||||
summon_id = raw_summon['master']['id']
|
||||
summon = Summon.find_by(granblue_id: transform_id(summon_id))
|
||||
|
||||
position = if sub
|
||||
key.to_i + 4
|
||||
else
|
||||
key.to_i == 1 ? -1 : key.to_i - 2
|
||||
end
|
||||
|
||||
GridSummon.new({
|
||||
party: @party,
|
||||
summon: summon,
|
||||
position: position,
|
||||
main: key.to_i == 1,
|
||||
friend: false,
|
||||
quick_summon: summon_params['id'].to_i == internal_quick_summon_id,
|
||||
uncap_level: summon_params['evolution'].to_i,
|
||||
transcendence_step: level_to_transcendence(summon_params['level'].to_i),
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes friend summon data and creates a GridSummon record.
|
||||
#
|
||||
# @return [GridSummon]
|
||||
def process_friend_summon
|
||||
summon_name = @data.dig('deck', 'pc', 'damage_info', 'summon_name')
|
||||
summon = Summon.find_by('name_en = ? OR name_jp = ?', summon_name, summon_name)
|
||||
|
||||
GridSummon.new({
|
||||
party: @party,
|
||||
summon: summon,
|
||||
position: 4,
|
||||
main: false,
|
||||
friend: true,
|
||||
quick_summon: false,
|
||||
uncap_level: determine_uncap_level(summon),
|
||||
transcendence_step: summon.transcendence ? 5 : 0,
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now
|
||||
})
|
||||
end
|
||||
|
||||
##
|
||||
# Determines the numeric uncap level of a given Summon
|
||||
#
|
||||
# @param summon [Summon] the canonical summon
|
||||
# @return [Integer]
|
||||
def determine_uncap_level(summon)
|
||||
if summon.transcendence
|
||||
6
|
||||
elsif summon.ulb
|
||||
5
|
||||
elsif summon.flb
|
||||
4
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Determines the uncap level for a friend summon based on its ULb and FLb flags.
|
||||
#
|
||||
# @param summon_data [Hash] the summon data.
|
||||
# @return [Integer] the computed uncap level.
|
||||
def determine_friend_uncap(summon_data)
|
||||
if summon_data[:ulb]
|
||||
5
|
||||
elsif summon_data[:flb]
|
||||
4
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Converts a given level, rounded down to the nearest 10,
|
||||
# to its corresponding transcendence step.
|
||||
#
|
||||
# If level is 200, returns 0; if level is 250, returns 5.
|
||||
#
|
||||
# @param level [Integer] the summon's level
|
||||
# @return [Integer] the transcendence step
|
||||
def level_to_transcendence(level)
|
||||
return 0 if level < 200
|
||||
|
||||
floored_level = (level / 10).floor * 10
|
||||
TRANSCENDENCE_LEVELS.index(floored_level)
|
||||
end
|
||||
|
||||
##
|
||||
# Transforms 5★ Arcarum-series summon IDs into their 4★ variants,
|
||||
# as that's what is stored in the database.
|
||||
#
|
||||
# If an unrelated ID, or the 4★ ID is passed, then returns the input.
|
||||
#
|
||||
# @param id [String] the ID to match
|
||||
# @return [String] the resulting ID
|
||||
def transform_id(id)
|
||||
mapping = {
|
||||
'2040315000' => '2040238000',
|
||||
'2040316000' => '2040239000',
|
||||
'2040314000' => '2040237000',
|
||||
'2040313000' => '2040236000',
|
||||
'2040321000' => '2040244000',
|
||||
'2040319000' => '2040242000',
|
||||
'2040317000' => '2040240000',
|
||||
'2040322000' => '2040245000',
|
||||
'2040318000' => '2040241000',
|
||||
'2040320000' => '2040243000'
|
||||
}
|
||||
|
||||
# If the id is a key, return the mapped value; otherwise, return the id.
|
||||
mapping[id] || id
|
||||
end
|
||||
end
|
||||
end
|
||||
395
app/services/processors/weapon_processor.rb
Normal file
395
app/services/processors/weapon_processor.rb
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Processors
|
||||
##
|
||||
# WeaponProcessor processes weapon data from a deck JSON and creates GridWeapon records.
|
||||
# It follows a similar error‐handling and implementation strategy as SummonProcessor.
|
||||
#
|
||||
# Expected data format (excerpt):
|
||||
# {
|
||||
# "deck": {
|
||||
# "pc": {
|
||||
# "weapons": {
|
||||
# "1": {
|
||||
# "param": {
|
||||
# "uncap": 3,
|
||||
# "level": "150",
|
||||
# "augment_skill_info": [ [ { "skill_id": 1588, "effect_value": "3", "show_value": "3%" }, ... ] ],
|
||||
# "arousal": {
|
||||
# "is_arousal_weapon": true,
|
||||
# "level": 4,
|
||||
# "skill": [ { "skill_id": 1896, ... }, ... ]
|
||||
# },
|
||||
# ...
|
||||
# },
|
||||
# "master": {
|
||||
# "id": "1040215100",
|
||||
# "name": "Wamdus's Cnidocyte",
|
||||
# "attribute": "2",
|
||||
# ...
|
||||
# },
|
||||
# "keys": [ "..." ] // optional
|
||||
# },
|
||||
# "2": { ... },
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# The processor also uses an AX_MAPPING to convert in‐game AX skill IDs to our stored values.
|
||||
class WeaponProcessor < BaseProcessor
|
||||
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
|
||||
|
||||
# Mapping from in‑game AX skill IDs (as strings) to our internal modifier values.
|
||||
AX_MAPPING = {
|
||||
'1588' => 2,
|
||||
'1589' => 0,
|
||||
'1590' => 1,
|
||||
'1591' => 3,
|
||||
'1592' => 4,
|
||||
'1593' => 9,
|
||||
'1594' => 13,
|
||||
'1595' => 10,
|
||||
'1596' => 5,
|
||||
'1597' => 6,
|
||||
'1599' => 8,
|
||||
'1600' => 12,
|
||||
'1601' => 11,
|
||||
'1719' => 15,
|
||||
'1720' => 16,
|
||||
'1721' => 17,
|
||||
'1722' => 14
|
||||
}.freeze
|
||||
|
||||
# KEY_MAPPING maps the raw key value (as a string) to a canonical range or value.
|
||||
# For example, in our test we want a raw key "10001" to be interpreted as any key whose
|
||||
# canonical granblue_id is between 697 and 706.
|
||||
KEY_MAPPING = {
|
||||
'10001' => %w[697 698 699 700 701 702 703 704 705 706],
|
||||
'10002' => %w[707 708 709 710 711 712 713 714 715 716],
|
||||
'10003' => %w[717 718 719 720 721 722 723 724 725 726],
|
||||
'10004' => %w[727 728 729 730 731 732 733 734 735 736],
|
||||
'10005' => %w[737 738 739 740 741 742 743 744 745 746],
|
||||
'10006' => %w[747 748 749 750 751 752 753 754 755 756],
|
||||
'11001' => '758',
|
||||
'11002' => '759',
|
||||
'11003' => '760',
|
||||
'11004' => '760',
|
||||
'13001' => %w[1240 2204 2208], # α Pendulum
|
||||
'13002' => %w[1241 2205 2209], # β Pendulum
|
||||
'13003' => %w[1242 2206 2210], # γ Pendulum
|
||||
'13004' => %w[1243 2207 2211], # Δ Pendulum
|
||||
'14001' => %w[502 503 504 505 506 507 1213 1214 1215 1216 1217 1218], # Pendulum of Strength
|
||||
'14002' => %w[130 131 132 133 134 135 71 72 73 74 75 76], # Pendulum of Zeal
|
||||
'14003' => %w[1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271], # Pendulum of Strife
|
||||
'14004' => %w[1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210], # Pendulum of Prosperity
|
||||
'14005' => %w[2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223], # Pendulum of Extremity
|
||||
'14006' => %w[2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235], # Pendulum of Sagacity
|
||||
'14007' => %w[2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247], # Pendulum of Supremacy
|
||||
'14011' => %w[322 323 324 325 326 327 1310 1311 1312 1313 1314 1315], # Chain of Temperament
|
||||
'14012' => %w[764 765 766 767 768 769 1731 1732 1733 1734 1735 948], # Chain of Restoration
|
||||
'14013' => %w[1171 1172 1173 1174 1175 1176 1736 1737 1738 1739 1740 1741], # Chain of Glorification
|
||||
'14014' => '1723', # Chain of Temptation
|
||||
'14015' => '1724', # Chain of Forbiddance
|
||||
'14016' => '1725', # Chain of Depravity
|
||||
'14017' => '1726', # Chain of Falsehood
|
||||
'15001' => '1446',
|
||||
'15002' => '1447',
|
||||
'15003' => '1448', # Abyss Teluma
|
||||
'15004' => '1449', # Crag Teluma
|
||||
'15005' => '1450', # Tempest Teluma
|
||||
'15006' => '1451',
|
||||
'15007' => '1452', # Malice Teluma
|
||||
'15008' => %w[2043 2044 2045 2046 2047 2048],
|
||||
'15009' => %w[2049 2050 2051 2052 2053 2054], # Oblivion Teluma
|
||||
'16001' => %w[1228 1229 1230 1231 1232 1233], # Optimus Teluma
|
||||
'16002' => %w[1234 1235 1236 1237 1238 1239], # Omega Teluma
|
||||
'17001' => '1807',
|
||||
'17002' => '1808',
|
||||
'17003' => '1809',
|
||||
'17004' => '1810',
|
||||
# Emblems (series {24})
|
||||
'3' => '3',
|
||||
'2' => '2',
|
||||
'1' => '1'
|
||||
}.freeze
|
||||
|
||||
AWAKENING_MAPPING = {
|
||||
'1' => 'weapon-atk',
|
||||
'2' => 'weapon-def',
|
||||
'3' => 'weapon-special',
|
||||
'4' => 'weapon-ca',
|
||||
'5' => 'weapon-skill',
|
||||
'6' => 'weapon-heal',
|
||||
'7' => 'weapon-multi'
|
||||
}.freeze
|
||||
|
||||
ELEMENTAL_WEAPON_MAPPING = %w[1040914600 1040810100 1040506800 1040312000 1040513800 1040810900 1040910300
|
||||
1040114200 1040027000 1040807600 1040120300 1040318500 1040710000 1040608100
|
||||
1040812100 1040307200 1040410200 1040510600 1040018100 1040113400 1040017300
|
||||
1040011900 1040412200 1040508000 1040512600 1040609100 1040411600 1040208800
|
||||
1040906900 1040909300 1040509700 1040014400 1040308400 1040613100 1040013200
|
||||
1040011300 1040413400 1040607500 1040504400 1040703600 1040406000 1040601700
|
||||
1040904300 1040109700 1040900300 1040002000 1040807200 1040102900 1040203000
|
||||
1040402800 1040507400 1040200900 1040307800 1040501600 1040706900 1040604200
|
||||
1040103000 1040003500 1040300100 1040907500 1040105500 1040106600 1040503500
|
||||
1040801300 1040410800 1040702700 1040006200 1040302300 1040803700 1040900400
|
||||
1040406900 1040109100 1040111600 1040706300 1040806400 1040209700 1040707500
|
||||
1040208200 1040214000 1040021100 1040417200 1040012600 1040317500 1040402900].freeze
|
||||
ELEMENTAL_WEAPON_MAPPING_INT = ELEMENTAL_WEAPON_MAPPING.map(&:to_i).sort.freeze
|
||||
|
||||
ELEMENT_MAPPING = {
|
||||
0 => nil,
|
||||
1 => 4, # Wind -> Earth
|
||||
2 => 2, # Fire -> Fire
|
||||
3 => 3, # Water -> Water
|
||||
4 => 1, # Earth -> Wind
|
||||
5 => 6, # Dark -> Light
|
||||
6 => 5 # Light -> Dark
|
||||
}.freeze
|
||||
##
|
||||
# Initializes a new WeaponProcessor.
|
||||
#
|
||||
# @param party [Party] the Party record.
|
||||
# @param data [Hash] the full deck JSON.
|
||||
# @param type [Symbol] (optional) processing type.
|
||||
# @param options [Hash] additional options.
|
||||
def initialize(party, data, type = :normal, options = {})
|
||||
super(party, data, options)
|
||||
@party = party
|
||||
@data = data
|
||||
end
|
||||
|
||||
##
|
||||
# Processes the deck’s weapon data and creates GridWeapon records.
|
||||
#
|
||||
# It expects the incoming data to be a Hash that contains:
|
||||
# "deck" → "pc" → "weapons"
|
||||
#
|
||||
# @return [void]
|
||||
def process
|
||||
unless @data.is_a?(Hash)
|
||||
Rails.logger.error "[WEAPON] Invalid data format: expected a Hash, got #{@data.class}"
|
||||
return
|
||||
end
|
||||
|
||||
unless @data.key?('deck') && @data['deck'].key?('pc') && @data['deck']['pc'].key?('weapons')
|
||||
Rails.logger.error '[WEAPON] Missing weapons data in deck JSON'
|
||||
return
|
||||
end
|
||||
|
||||
@data = @data.with_indifferent_access
|
||||
weapons_data = @data['deck']['pc']['weapons']
|
||||
|
||||
grid_weapons = process_weapons(weapons_data)
|
||||
|
||||
grid_weapons.each do |grid_weapon|
|
||||
begin
|
||||
grid_weapon.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "[WEAPON] Failed to create GridWeapon: #{e.record.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Processes a hash of raw weapon data and returns an array of GridWeapon records.
|
||||
#
|
||||
# @param weapons_data [Hash] the raw weapons data (keyed by slot number).
|
||||
# @return [Array<GridWeapon>]
|
||||
def process_weapons(weapons_data)
|
||||
weapons_data.map do |key, raw_weapon|
|
||||
next if raw_weapon.nil? || raw_weapon['param'].nil? || raw_weapon['master'].nil?
|
||||
|
||||
position = key.to_i == 1 ? -1 : key.to_i - 2
|
||||
mainhand = (position == -1)
|
||||
|
||||
uncap_level = raw_weapon.dig('param', 'uncap').to_i
|
||||
level = raw_weapon.dig('param', 'level').to_i
|
||||
transcendence_step = level_to_transcendence(level)
|
||||
series = raw_weapon.dig('master', 'series_id')
|
||||
weapon_id = raw_weapon.dig('master', 'id')
|
||||
|
||||
processed_weapon_id = if Weapon.element_changeable?(series)
|
||||
process_elemental_weapon(weapon_id)
|
||||
else
|
||||
weapon_id
|
||||
end
|
||||
|
||||
processed_element = if Weapon.element_changeable?(series)
|
||||
ELEMENT_MAPPING[raw_weapon.dig('master', 'attribute')]
|
||||
end
|
||||
|
||||
weapon = Weapon.find_by(granblue_id: processed_weapon_id)
|
||||
|
||||
unless weapon
|
||||
Rails.logger.error "[WEAPON] Weapon not found with id #{processed_weapon_id}"
|
||||
next
|
||||
end
|
||||
|
||||
grid_weapon = GridWeapon.new(
|
||||
party: @party,
|
||||
weapon: weapon,
|
||||
position: position,
|
||||
mainhand: mainhand,
|
||||
uncap_level: uncap_level,
|
||||
transcendence_step: transcendence_step,
|
||||
element: processed_element
|
||||
)
|
||||
|
||||
arousal_data = raw_weapon.dig('param', 'arousal')
|
||||
if arousal_data && arousal_data['is_arousal_weapon']
|
||||
grid_weapon.awakening_id = map_arousal_to_awakening(arousal_data)
|
||||
grid_weapon.awakening_level = arousal_data['level'].to_i.positive? ? arousal_data['level'].to_i : 1
|
||||
end
|
||||
|
||||
# Extract skill IDs and convert into weapon keys
|
||||
skill_ids = [raw_weapon['skill1'], raw_weapon['skill2'], raw_weapon['skill3']].compact.map { |s| s['id'] }
|
||||
process_weapon_keys(grid_weapon, skill_ids) if skill_ids.length.positive?
|
||||
|
||||
if raw_weapon.dig('param', 'augment_skill_info').present?
|
||||
process_weapon_ax(grid_weapon, raw_weapon.dig('param', 'augment_skill_info'))
|
||||
end
|
||||
|
||||
grid_weapon
|
||||
end.compact
|
||||
end
|
||||
|
||||
##
|
||||
# Converts a given weapon level to a transcendence step.
|
||||
#
|
||||
# If the level is less than 200, returns 0; otherwise, floors the level
|
||||
# to the nearest 10 and returns its index in TRANSCENDENCE_LEVELS.
|
||||
#
|
||||
# @param level [Integer] the weapon’s level.
|
||||
# @return [Integer] the transcendence step.
|
||||
def level_to_transcendence(level)
|
||||
return 0 if level < 200
|
||||
|
||||
floored_level = (level / 10).floor * 10
|
||||
TRANSCENDENCE_LEVELS.index(floored_level) || 0
|
||||
end
|
||||
|
||||
##
|
||||
# Processes weapon key data and assigns them to the grid_weapon.
|
||||
#
|
||||
# @param grid_weapon [GridWeapon] the grid weapon record being built.
|
||||
# @param skill_ids [Array<String>] an array of key identifiers.
|
||||
# @return [void]
|
||||
def process_weapon_keys(grid_weapon, skill_ids)
|
||||
series = grid_weapon.weapon.series.to_i
|
||||
|
||||
skill_ids.each_with_index do |skill_id, idx|
|
||||
# Go to the next iteration unless the key under which `skill_id` exists
|
||||
mapping_pair = KEY_MAPPING.find { |key, value| Array(value).include?(skill_id) }
|
||||
next unless mapping_pair
|
||||
|
||||
# Fetch the key from the mapping_pair and find the weapon key based on the weapon series
|
||||
mapping_value = mapping_pair.first
|
||||
candidate = WeaponKey.where('granblue_id = ? AND ? = ANY(series)', mapping_value, series).first
|
||||
|
||||
if candidate
|
||||
grid_weapon["weapon_key#{idx + 1}_id"] = candidate.id
|
||||
else
|
||||
Rails.logger.warn "[WEAPON] No matching WeaponKey found for raw key #{skill_id} using mapping #{mapping_value}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Returns true if the candidate key (a string) matches the mapping entry.
|
||||
#
|
||||
# If mapping_entry includes a dash, it is interpreted as a range (e.g. "697-706").
|
||||
# Otherwise, it must match exactly.
|
||||
#
|
||||
# @param candidate_key [String] the candidate WeaponKey.granblue_id.
|
||||
# @param mapping_entry [String] the mapping entry.
|
||||
# @return [Boolean]
|
||||
def matches_key?(candidate_key, mapping_entry)
|
||||
if mapping_entry.include?('-')
|
||||
left, right = mapping_entry.split('-').map(&:to_i)
|
||||
candidate_key.to_i >= left && candidate_key.to_i <= right
|
||||
else
|
||||
candidate_key == mapping_entry
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes AX (augment) skill data.
|
||||
#
|
||||
# The deck stores AX skills in an array of arrays under "augment_skill_info".
|
||||
# This method flattens the data and assigns each skill’s modifier and strength.
|
||||
#
|
||||
# @param grid_weapon [GridWeapon] the grid weapon record being built.
|
||||
# @param ax_skill_info [Array] the raw AX skill info.
|
||||
# @return [void]
|
||||
def process_weapon_ax(grid_weapon, ax_skill_info)
|
||||
# Flatten the nested array structure.
|
||||
ax_skills = ax_skill_info.flatten
|
||||
ax_skills.each_with_index do |ax, idx|
|
||||
ax_id = ax['skill_id'].to_s
|
||||
ax_mod = AX_MAPPING[ax_id] || ax_id.to_i
|
||||
strength = ax['effect_value'].to_s.gsub(/[+%]/, '').to_i
|
||||
grid_weapon["ax_modifier#{idx + 1}"] = ax_mod
|
||||
grid_weapon["ax_strength#{idx + 1}"] = strength
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Maps the in‑game awakening data (stored under "arousal") to our Awakening record.
|
||||
#
|
||||
# This method looks at the "skill" array inside the arousal data and uses the first
|
||||
# awakening’s skill_id to find the corresponding Awakening record.
|
||||
#
|
||||
# @param arousal_data [Hash] the raw arousal (awakening) data.
|
||||
# @return [String, nil] the database awakening id or nil if not found.
|
||||
def map_arousal_to_awakening(arousal_data)
|
||||
raw_data = arousal_data.with_indifferent_access
|
||||
|
||||
return nil if raw_data.nil?
|
||||
return nil unless raw_data.is_a?(Hash)
|
||||
return nil unless raw_data.has_key?('form')
|
||||
|
||||
id = (raw_data['form']).to_s
|
||||
return unless AWAKENING_MAPPING.key?(id)
|
||||
|
||||
slug = AWAKENING_MAPPING[id]
|
||||
awakening = Awakening.find_by(slug: slug)
|
||||
|
||||
awakening&.id
|
||||
end
|
||||
|
||||
def process_elemental_weapon(granblue_id)
|
||||
granblue_int = granblue_id.to_i
|
||||
|
||||
# Find the index of the first element that is >= granblue_int.
|
||||
idx = ELEMENTAL_WEAPON_MAPPING_INT.bsearch_index { |x| x >= granblue_int }
|
||||
|
||||
# We'll check the candidate at idx and the one immediately before it.
|
||||
candidates = []
|
||||
if idx
|
||||
candidate = ELEMENTAL_WEAPON_MAPPING_INT[idx]
|
||||
candidates << candidate if (granblue_int - candidate).abs <= 500
|
||||
# Check the candidate just before, if it exists.
|
||||
if idx > 0
|
||||
candidate_prev = ELEMENTAL_WEAPON_MAPPING_INT[idx - 1]
|
||||
candidates << candidate_prev if (granblue_int - candidate_prev).abs <= 500
|
||||
end
|
||||
else
|
||||
# If idx is nil, then granblue_int is greater than all mapped values.
|
||||
candidate = ELEMENTAL_WEAPON_MAPPING_INT.last
|
||||
candidates << candidate if (granblue_int - candidate).abs <= 500
|
||||
end
|
||||
|
||||
# If no candidate is close enough, return the original input.
|
||||
return granblue_id if candidates.empty?
|
||||
|
||||
# Choose the candidate with the smallest difference.
|
||||
best_match = candidates.min_by { |x| (granblue_int - x).abs }
|
||||
best_match.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -34,6 +34,8 @@ module HenseiApi
|
|||
config.active_record.query_log_tags = [:application, :controller, :action, :job]
|
||||
config.active_record.cache_query_log_tags = true
|
||||
|
||||
config.active_support.to_time_preserves_timezone = :zone
|
||||
|
||||
# API-only application configuration
|
||||
config.api_only = true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
require "active_support/core_ext/integer/time"
|
||||
require 'active_support/core_ext/integer/time'
|
||||
|
||||
# The test environment is used exclusively to run your application's
|
||||
# test suite. You never need to work with it otherwise. Remember that
|
||||
|
|
@ -9,21 +9,21 @@ Rails.application.configure do
|
|||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
|
||||
# Turn false under Spring and add config.action_view.cache_template_loading = true.
|
||||
config.cache_classes = true
|
||||
config.cache_classes = false
|
||||
|
||||
# Eager loading loads your whole application. When running a single test locally,
|
||||
# this probably isn't necessary. It's a good idea to do in a continuous integration
|
||||
# system, or in some way before deploying your code.
|
||||
config.eager_load = ENV["CI"].present?
|
||||
config.eager_load = ENV['CI'].present?
|
||||
|
||||
# Configure public file server for tests with Cache-Control for performance.
|
||||
config.public_file_server.enabled = true
|
||||
config.public_file_server.headers = {
|
||||
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
|
||||
'Cache-Control' => "public, max-age=#{1.hour.to_i}"
|
||||
}
|
||||
|
||||
# Show full error reports and disable caching.
|
||||
config.consider_all_requests_local = true
|
||||
config.consider_all_requests_local = true
|
||||
config.action_controller.perform_caching = false
|
||||
config.cache_store = :null_store
|
||||
|
||||
|
|
|
|||
4
config/initializers/oj.rb
Normal file
4
config/initializers/oj.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Explicitly configure Oj to mimic JSON.
|
||||
Oj::Rails.mimic_JSON
|
||||
7
config/initializers/sentry.rb
Normal file
7
config/initializers/sentry.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sentry.init do |config|
|
||||
config.breadcrumbs_logger = [:active_support_logger]
|
||||
config.dsn = ENV['SENTRY_DSN']
|
||||
config.enable_tracing = true
|
||||
end
|
||||
68
config/newrelic.yml
Normal file
68
config/newrelic.yml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#
|
||||
# This file configures the New Relic Agent. New Relic monitors Ruby, Java,
|
||||
# .NET, PHP, Python, Node, and Go applications with deep visibility and low
|
||||
# overhead. For more information, visit www.newrelic.com.
|
||||
#
|
||||
# Generated October 28, 2022
|
||||
#
|
||||
# This configuration file is custom generated for NewRelic Administration
|
||||
#
|
||||
# For full documentation of agent configuration options, please refer to
|
||||
# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration
|
||||
|
||||
common: &default_settings
|
||||
# Required license key associated with your New Relic account.
|
||||
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
|
||||
|
||||
# Your application name. Renaming here affects where data displays in New
|
||||
# Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications
|
||||
app_name: 'hensei-api'
|
||||
|
||||
distributed_tracing:
|
||||
enabled: true
|
||||
|
||||
# To disable the agent regardless of other settings, uncomment the following:
|
||||
|
||||
# agent_enabled: false
|
||||
|
||||
log_file_path: logs/
|
||||
|
||||
# Logging level for log/newrelic_agent.log
|
||||
log_level: info
|
||||
|
||||
application_logging:
|
||||
# If `true`, all logging-related features for the agent can be enabled or disabled
|
||||
# independently. If `false`, all logging-related features are disabled.
|
||||
enabled: true
|
||||
forwarding:
|
||||
# If `true`, the agent captures log records emitted by this application.
|
||||
enabled: true
|
||||
# Defines the maximum number of log records to buffer in memory at a time.
|
||||
max_samples_stored: 10000
|
||||
metrics:
|
||||
# If `true`, the agent captures metrics related to logging for this application.
|
||||
enabled: true
|
||||
local_decorating:
|
||||
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans.
|
||||
# This requires a log forwarder to send your log files to New Relic.
|
||||
# This should not be used when forwarding is enabled.
|
||||
enabled: false
|
||||
|
||||
# Environment-specific settings are in this section.
|
||||
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
|
||||
# If your application has other named environments, configure them here.
|
||||
development:
|
||||
<<: *default_settings
|
||||
app_name: 'hensei-api (Development)'
|
||||
|
||||
test:
|
||||
<<: *default_settings
|
||||
# It doesn't make sense to report to New Relic from automated test runs.
|
||||
monitor_mode: false
|
||||
|
||||
staging:
|
||||
<<: *default_settings
|
||||
app_name: 'hensei-api (Staging)'
|
||||
|
||||
production:
|
||||
<<: *default_settings
|
||||
|
|
@ -4,20 +4,20 @@
|
|||
# the maximum value specified for Puma. Default is set to 5 threads for minimum
|
||||
# and maximum; this matches the default thread size of Active Record.
|
||||
#
|
||||
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
|
||||
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
|
||||
max_threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 }
|
||||
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
|
||||
threads min_threads_count, max_threads_count
|
||||
|
||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||
#
|
||||
port ENV.fetch("PORT") { 3000 }
|
||||
port ENV.fetch('PORT', 3000)
|
||||
|
||||
# Specifies the `environment` that Puma will run in.
|
||||
#
|
||||
environment ENV.fetch("RAILS_ENV") { "development" }
|
||||
environment ENV.fetch('RAILS_ENV') { 'development' }
|
||||
|
||||
# Specifies the `pidfile` that Puma will use.
|
||||
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
|
||||
pidfile ENV.fetch('PIDFILE') { 'tmp/pids/server.pid' }
|
||||
|
||||
# Specifies the number of `workers` to boot in clustered mode.
|
||||
# Workers are forked web server processes. If using threads and workers together
|
||||
|
|
|
|||
112
config/routes.rb
112
config/routes.rb
|
|
@ -4,75 +4,79 @@ Rails.application.routes.draw do
|
|||
skip_controllers :applications, :authorized_applications
|
||||
end
|
||||
|
||||
namespace :api, defaults: { format: :json } do
|
||||
namespace :v1 do
|
||||
resources :parties, only: %i[index create update destroy]
|
||||
resources :users, only: %i[create update show]
|
||||
resources :grid_weapons, only: %i[update destroy]
|
||||
resources :grid_characters, only: %i[update destroy]
|
||||
resources :grid_summons, only: %i[update destroy]
|
||||
resources :weapons, only: :show
|
||||
resources :characters, only: :show
|
||||
resources :summons, only: :show
|
||||
resources :favorites, only: [:create]
|
||||
path_prefix = Rails.env.production? ? '/v1' : '/api/v1'
|
||||
|
||||
get 'version', to: 'api#version'
|
||||
scope path: path_prefix, module: 'api/v1', defaults: { format: :json } do
|
||||
resources :parties, only: %i[index create update destroy]
|
||||
resources :users, only: %i[create update show]
|
||||
resources :grid_weapons, only: %i[update destroy]
|
||||
resources :grid_characters, only: %i[update destroy]
|
||||
resources :grid_summons, only: %i[update destroy]
|
||||
resources :weapons, only: :show
|
||||
resources :characters, only: :show
|
||||
resources :summons, only: :show
|
||||
resources :favorites, only: [:create]
|
||||
|
||||
post 'import', to: 'import#create'
|
||||
get 'version', to: 'api#version'
|
||||
|
||||
get 'users/info/:id', to: 'users#info'
|
||||
post 'import', to: 'import#create'
|
||||
post 'import/weapons', to: 'import#weapons'
|
||||
post 'import/summons', to: 'import#summons'
|
||||
post 'import/characters', to: 'import#characters'
|
||||
|
||||
get 'parties/favorites', to: 'parties#favorites'
|
||||
get 'parties/:id', to: 'parties#show'
|
||||
get 'parties/:id/preview', to: 'parties#preview'
|
||||
post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview'
|
||||
post 'parties/:id/remix', to: 'parties#remix'
|
||||
get 'users/info/:id', to: 'users#info'
|
||||
|
||||
put 'parties/:id/jobs', to: 'jobs#update_job'
|
||||
put 'parties/:id/job_skills', to: 'jobs#update_job_skills'
|
||||
delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill'
|
||||
get 'parties/favorites', to: 'parties#favorites'
|
||||
get 'parties/:id', to: 'parties#show'
|
||||
get 'parties/:id/preview', to: 'parties#preview'
|
||||
get 'parties/:id/preview_status', to: 'parties#preview_status'
|
||||
post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview'
|
||||
post 'parties/:id/remix', to: 'parties#remix'
|
||||
|
||||
post 'check/email', to: 'users#check_email'
|
||||
post 'check/username', to: 'users#check_username'
|
||||
put 'parties/:id/jobs', to: 'jobs#update_job'
|
||||
put 'parties/:id/job_skills', to: 'jobs#update_job_skills'
|
||||
delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill'
|
||||
|
||||
post 'search', to: 'search#all'
|
||||
post 'search/characters', to: 'search#characters'
|
||||
post 'search/weapons', to: 'search#weapons'
|
||||
post 'search/summons', to: 'search#summons'
|
||||
post 'search/job_skills', to: 'search#job_skills'
|
||||
post 'search/guidebooks', to: 'search#guidebooks'
|
||||
post 'check/email', to: 'users#check_email'
|
||||
post 'check/username', to: 'users#check_username'
|
||||
|
||||
get 'jobs', to: 'jobs#all'
|
||||
post 'search', to: 'search#all'
|
||||
post 'search/characters', to: 'search#characters'
|
||||
post 'search/weapons', to: 'search#weapons'
|
||||
post 'search/summons', to: 'search#summons'
|
||||
post 'search/job_skills', to: 'search#job_skills'
|
||||
post 'search/guidebooks', to: 'search#guidebooks'
|
||||
|
||||
get 'jobs/skills', to: 'job_skills#all'
|
||||
get 'jobs/:id', to: 'jobs#show'
|
||||
get 'jobs/:id/skills', to: 'job_skills#job'
|
||||
get 'jobs/:id/accessories', to: 'job_accessories#job'
|
||||
get 'jobs', to: 'jobs#all'
|
||||
|
||||
get 'guidebooks', to: 'guidebooks#all'
|
||||
get 'jobs/skills', to: 'job_skills#all'
|
||||
get 'jobs/:id', to: 'jobs#show'
|
||||
get 'jobs/:id/skills', to: 'job_skills#job'
|
||||
get 'jobs/:id/accessories', to: 'job_accessories#job'
|
||||
|
||||
get 'raids', to: 'raids#all'
|
||||
get 'raids/groups', to: 'raids#groups'
|
||||
get 'raids/:id', to: 'raids#show'
|
||||
get 'weapon_keys', to: 'weapon_keys#all'
|
||||
get 'guidebooks', to: 'guidebooks#all'
|
||||
|
||||
post 'characters', to: 'grid_characters#create'
|
||||
post 'characters/resolve', to: 'grid_characters#resolve'
|
||||
post 'characters/update_uncap', to: 'grid_characters#update_uncap_level'
|
||||
delete 'characters', to: 'grid_characters#destroy'
|
||||
get 'raids', to: 'raids#all'
|
||||
get 'raids/groups', to: 'raids#groups'
|
||||
get 'raids/:id', to: 'raids#show'
|
||||
get 'weapon_keys', to: 'weapon_keys#all'
|
||||
|
||||
post 'weapons', to: 'grid_weapons#create'
|
||||
post 'weapons/resolve', to: 'grid_weapons#resolve'
|
||||
post 'weapons/update_uncap', to: 'grid_weapons#update_uncap_level'
|
||||
delete 'weapons', to: 'grid_weapons#destroy'
|
||||
post 'characters', to: 'grid_characters#create'
|
||||
post 'characters/resolve', to: 'grid_characters#resolve'
|
||||
post 'characters/update_uncap', to: 'grid_characters#update_uncap_level'
|
||||
delete 'characters', to: 'grid_characters#destroy'
|
||||
|
||||
post 'summons', to: 'grid_summons#create'
|
||||
post 'summons/update_uncap', to: 'grid_summons#update_uncap_level'
|
||||
post 'summons/update_quick_summon', to: 'grid_summons#update_quick_summon'
|
||||
delete 'summons', to: 'grid_summons#destroy'
|
||||
post 'weapons', to: 'grid_weapons#create'
|
||||
post 'weapons/resolve', to: 'grid_weapons#resolve'
|
||||
post 'weapons/update_uncap', to: 'grid_weapons#update_uncap_level'
|
||||
delete 'weapons', to: 'grid_weapons#destroy'
|
||||
|
||||
delete 'favorites', to: 'favorites#destroy'
|
||||
end
|
||||
post 'summons', to: 'grid_summons#create'
|
||||
post 'summons/update_uncap', to: 'grid_summons#update_uncap_level'
|
||||
post 'summons/update_quick_summon', to: 'grid_summons#update_quick_summon'
|
||||
delete 'summons', to: 'grid_summons#destroy'
|
||||
|
||||
delete 'favorites', to: 'favorites#destroy'
|
||||
end
|
||||
|
||||
if Rails.env.development?
|
||||
|
|
|
|||
134
db/data/20250218023335_migrate_weapon_series.rb
Normal file
134
db/data/20250218023335_migrate_weapon_series.rb
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MigrateWeaponSeries < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
Weapon.transaction do
|
||||
puts 'Starting weapon series migration...'
|
||||
|
||||
puts 'Updating Seraphic Weapons (0 -> 1)...'
|
||||
Weapon.where(series: 0).update_all(new_series: 1)
|
||||
|
||||
puts 'Updating Grand Weapons (1 -> 2)...'
|
||||
Weapon.where(series: 1).update_all(new_series: 2)
|
||||
|
||||
puts 'Updating Dark Opus Weapons (2 -> 3)...'
|
||||
Weapon.where(series: 2).update_all(new_series: 3)
|
||||
|
||||
puts 'Updating Revenant Weapons (4 -> 4)...'
|
||||
Weapon.where(series: 4).update_all(new_series: 4)
|
||||
|
||||
puts 'Updating Primal Weapons (6 -> 5)...'
|
||||
Weapon.where(series: 6).update_all(new_series: 5)
|
||||
|
||||
puts 'Updating Beast Weapons (5, 7 -> 6)...'
|
||||
Weapon.where(series: 5).update_all(new_series: 6)
|
||||
Weapon.where(series: 7).update_all(new_series: 6)
|
||||
|
||||
puts 'Updating Regalia Weapons (8 -> 7)...'
|
||||
Weapon.where(series: 8).update_all(new_series: 7)
|
||||
|
||||
puts 'Updating Omega Weapons (9 -> 8)...'
|
||||
Weapon.where(series: 9).update_all(new_series: 8)
|
||||
|
||||
puts 'Updating Olden Primal Weapons (10 -> 9)...'
|
||||
Weapon.where(series: 10).update_all(new_series: 9)
|
||||
|
||||
puts 'Updating Hollowsky Weapons (12 -> 10)...'
|
||||
Weapon.where(series: 12).update_all(new_series: 10)
|
||||
|
||||
puts 'Updating Xeno Weapons (13 -> 11)...'
|
||||
Weapon.where(series: 13).update_all(new_series: 11)
|
||||
|
||||
puts 'Updating Rose Weapons (15 -> 12)...'
|
||||
Weapon.where(series: 15).update_all(new_series: 12)
|
||||
|
||||
puts 'Updating Ultima Weapons (17 -> 13)...'
|
||||
Weapon.where(series: 17).update_all(new_series: 13)
|
||||
|
||||
puts 'Updating Bahamut Weapons (16 -> 14)...'
|
||||
Weapon.where(series: 16).update_all(new_series: 14)
|
||||
|
||||
puts 'Updating Epic Weapons (18 -> 15)...'
|
||||
Weapon.where(series: 18).update_all(new_series: 15)
|
||||
|
||||
puts 'Updating Cosmos Weapons (20 -> 16)...'
|
||||
Weapon.where(series: 20).update_all(new_series: 16)
|
||||
|
||||
puts 'Updating Superlative Weapons (22 -> 17)...'
|
||||
Weapon.where(series: 22).update_all(new_series: 17)
|
||||
|
||||
puts 'Updating Vintage Weapons (23 -> 18)...'
|
||||
Weapon.where(series: 23).update_all(new_series: 18)
|
||||
|
||||
puts 'Updating Class Champion Weapons (24 -> 19)...'
|
||||
Weapon.where(series: 24).update_all(new_series: 19)
|
||||
|
||||
puts 'Updating Sephira Weapons (28 -> 23)...'
|
||||
Weapon.where(series: 28).update_all(new_series: 23)
|
||||
|
||||
puts 'Updating Astral Weapons (14 -> 26)...'
|
||||
Weapon.where(series: 14).update_all(new_series: 26)
|
||||
|
||||
puts 'Updating Draconic Weapons (3 -> 27)...'
|
||||
Weapon.where(series: 3).update_all(new_series: 27)
|
||||
|
||||
puts 'Updating Ancestral Weapons (21 -> 29)...'
|
||||
Weapon.where(series: 21).update_all(new_series: 29)
|
||||
|
||||
puts 'Updating New World Foundation (29 -> 30)...'
|
||||
Weapon.where(series: 29).update_all(new_series: 30)
|
||||
|
||||
puts 'Updating Ennead Weapons (19 -> 31)...'
|
||||
Weapon.where(series: 19).update_all(new_series: 31)
|
||||
|
||||
puts 'Updating Militis Weapons (11 -> 32)...'
|
||||
Weapon.where(series: 11).update_all(new_series: 32)
|
||||
|
||||
puts 'Updating Malice Weapons (26 -> 33)...'
|
||||
Weapon.where(series: 26).update_all(new_series: 33)
|
||||
|
||||
puts 'Updating Menace Weapons (26 -> 34)...'
|
||||
Weapon.where(series: 26).update_all(new_series: 34)
|
||||
|
||||
puts 'Updating Illustrious Weapons (31 -> 35)...'
|
||||
Weapon.where(series: 31).update_all(new_series: 35)
|
||||
|
||||
puts 'Updating Proven Weapons (25 -> 36)...'
|
||||
Weapon.where(series: 25).update_all(new_series: 36)
|
||||
|
||||
puts 'Updating Revans Weapons (30 -> 37)...'
|
||||
Weapon.where(series: 30).update_all(new_series: 37)
|
||||
|
||||
puts 'Updating World Weapons (32 -> 38)...'
|
||||
Weapon.where(series: 32).update_all(new_series: 38)
|
||||
|
||||
puts 'Updating Exo Weapons (33 -> 39)...'
|
||||
Weapon.where(series: 33).update_all(new_series: 39)
|
||||
|
||||
puts 'Updating Draconic Weapons Providence (34 -> 40)...'
|
||||
Weapon.where(series: 34).update_all(new_series: 40)
|
||||
|
||||
puts 'Updating Celestial Weapons (37 -> 41)...'
|
||||
Weapon.where(series: 37).update_all(new_series: 41)
|
||||
|
||||
puts 'Updating Omega Rebirth Weapons (38 -> 42)...'
|
||||
Weapon.where(series: 38).update_all(new_series: 42)
|
||||
|
||||
puts 'Updating Event Weapons (34 -> 98)...'
|
||||
Weapon.where(series: 34).update_all(new_series: 98) # Event
|
||||
|
||||
puts 'Updating Gacha Weapons (36 -> 99)...'
|
||||
Weapon.where(series: 36).update_all(new_series: 99) # Gacha
|
||||
|
||||
puts 'Migration completed successfully!'
|
||||
rescue StandardError => e
|
||||
puts "Error occurred during migration: #{e.message}"
|
||||
puts "Backtrace: #{e.backtrace}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
37
db/data/20250218025755_migrate_series_on_weapon_key.rb
Normal file
37
db/data/20250218025755_migrate_series_on_weapon_key.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MigrateSeriesOnWeaponKey < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
WeaponKey.transaction do
|
||||
puts 'Starting weapon key series migration...'
|
||||
|
||||
puts 'Updating Telumas (3 -> 27)...'
|
||||
WeaponKey.where('? = ANY(series)', 3).update_all('series = array_replace(series, 3, 27)')
|
||||
|
||||
puts 'Updating Providence Telumas (34 -> 40)...'
|
||||
WeaponKey.where('? = ANY(series)', 34).update_all('series = array_replace(series, 34, 40)')
|
||||
|
||||
puts 'Updating Gauph Keys (17 -> 13)...'
|
||||
WeaponKey.where('? = ANY(series)', 17).update_all('series = array_replace(series, 17, 13)')
|
||||
|
||||
puts 'Updating Pendulums (2 -> 3)...'
|
||||
WeaponKey.where('? = ANY(series)', 2).update_all('series = array_replace(series, 2, 3)')
|
||||
|
||||
puts 'Updating Chains (2 -> 3)...'
|
||||
WeaponKey.where('? = ANY(series)', 2).update_all('series = array_replace(series, 2, 3)')
|
||||
|
||||
puts 'Updating Emblems (24 -> 19)...'
|
||||
WeaponKey.where('? = ANY(series)', 24).update_all('series = array_replace(series, 24, 19)')
|
||||
|
||||
puts 'Migration completed successfully!'
|
||||
rescue StandardError => e
|
||||
puts "Error occurred during migration: #{e.message}"
|
||||
puts "Backtrace: #{e.backtrace}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20250115094623)
|
||||
DataMigrate::Data.define(version: 20250218025755)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
class MakeJobForeignKeyDeferrable < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_foreign_key :jobs, column: :base_job_id
|
||||
add_foreign_key :jobs, :jobs, column: :base_job_id, deferrable: :deferred, initially_deferred: true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
class RemoveForeignKeyConstraintOnJobsBaseJobId < ActiveRecord::Migration[8.0]
|
||||
# Removes the self-referential foreign key constraint on jobs.base_job_id.
|
||||
# This constraint was causing issues when seeding job records via CSV.
|
||||
def change
|
||||
# Check if the foreign key exists before removing it
|
||||
if foreign_key_exists?(:jobs, column: :base_job_id)
|
||||
remove_foreign_key :jobs, column: :base_job_id
|
||||
Rails.logger.info 'Removed foreign key constraint on jobs.base_job_id'
|
||||
else
|
||||
Rails.logger.info 'No foreign key on jobs.base_job_id found'
|
||||
end
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250218013315_add_new_series_to_weapons.rb
Normal file
5
db/migrate/20250218013315_add_new_series_to_weapons.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class AddNewSeriesToWeapons < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :weapons, :new_series, :integer
|
||||
end
|
||||
end
|
||||
6
db/migrate/20250218025315_move_new_series_to_series.rb
Normal file
6
db/migrate/20250218025315_move_new_series_to_series.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class MoveNewSeriesToSeries < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_column :weapons, :series
|
||||
rename_column :weapons, :new_series, :series
|
||||
end
|
||||
end
|
||||
15
db/migrate/20250301143956_add_raw_data_columns.rb
Normal file
15
db/migrate/20250301143956_add_raw_data_columns.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
class AddRawDataColumns < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :characters, :wiki_raw, :text
|
||||
add_column :characters, :game_raw_en, :text
|
||||
add_column :characters, :game_raw_jp, :text
|
||||
|
||||
add_column :summons, :wiki_raw, :text
|
||||
add_column :summons, :game_raw_en, :text
|
||||
add_column :summons, :game_raw_jp, :text
|
||||
|
||||
add_column :weapons, :wiki_raw, :text
|
||||
add_column :weapons, :game_raw_en, :text
|
||||
add_column :weapons, :game_raw_jp, :text
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
class AddClassicIiAndCollabToGacha < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :gacha, :classic_ii, :boolean, default: false
|
||||
add_column :gacha, :collab, :boolean, default: false
|
||||
end
|
||||
end
|
||||
15
db/schema.rb
15
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_02_01_170037) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
|
@ -67,6 +67,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
|
|||
t.string "kamigame", default: ""
|
||||
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.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
|
||||
|
|
@ -420,6 +423,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
|
|||
t.date "transcendence_date"
|
||||
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.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
|
||||
|
|
@ -465,7 +471,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
|
|||
t.integer "rarity"
|
||||
t.integer "element"
|
||||
t.integer "proficiency"
|
||||
t.integer "series", default: -1, null: false
|
||||
t.boolean "flb", default: false, null: false
|
||||
t.boolean "ulb", default: false, null: false
|
||||
t.integer "max_level", default: 100, null: false
|
||||
|
|
@ -495,6 +500,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
|
|||
t.boolean "transcendence", default: false
|
||||
t.date "transcendence_date"
|
||||
t.string "recruits"
|
||||
t.integer "series"
|
||||
t.integer "new_series"
|
||||
t.text "wiki_raw"
|
||||
t.text "game_raw_en"
|
||||
t.text "game_raw_jp"
|
||||
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"
|
||||
|
|
@ -511,7 +521,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
|
|||
add_foreign_key "grid_weapons", "parties"
|
||||
add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id"
|
||||
add_foreign_key "grid_weapons", "weapons"
|
||||
add_foreign_key "jobs", "jobs", column: "base_job_id"
|
||||
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
|
||||
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
|
||||
add_foreign_key "parties", "guidebooks", column: "guidebook1_id"
|
||||
|
|
|
|||
98
db/seed/canonical.rb
Normal file
98
db/seed/canonical.rb
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# canonical.rb - Loads canonical seed data from CSV files into the database.
|
||||
#
|
||||
# This file is used to load canonical data for various models from CSV files
|
||||
# located in db/seed/test. For models that reference other models by fixed IDs
|
||||
# (e.g. Job, Guidebook, etc.), use the `use_id: true` option to preserve the CSV
|
||||
# provided IDs (so that inter-model references remain correct).
|
||||
#
|
||||
# @example
|
||||
# load_csv_for(Character, 'characters_test.csv', :granblue_id)
|
||||
#
|
||||
# # For objects that need to preserve the CSV "id" column:
|
||||
# load_csv_for(Job, 'jobs_test.csv', :granblue_id, use_id: true)
|
||||
#
|
||||
require 'csv'
|
||||
|
||||
##
|
||||
# Processes specified columns in an attributes hash to booleans.
|
||||
#
|
||||
# @param attrs [Hash] The attributes hash.
|
||||
# @param columns [Array<Symbol>] The list of columns to cast to boolean.
|
||||
def process_booleans(attrs, columns)
|
||||
columns.each do |col|
|
||||
next unless attrs.key?(col) && attrs[col].present?
|
||||
# Use ActiveModel::Type::Boolean to cast the value.
|
||||
attrs[col] = ActiveModel::Type::Boolean.new.cast(attrs[col])
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Processes specified columns in an attributes hash to dates.
|
||||
#
|
||||
# @param attrs [Hash] The attributes hash.
|
||||
# @param columns [Array<Symbol>] The list of columns to parse as dates.
|
||||
def process_dates(attrs, columns)
|
||||
columns.each do |col|
|
||||
next unless attrs.key?(col) && attrs[col].present?
|
||||
# Parse the date, or assign nil if parsing fails.
|
||||
attrs[col] = Date.parse(attrs[col]) rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Loads CSV data for the given model class.
|
||||
#
|
||||
# Reads a CSV file from the db/seed/test directory and uses the given unique_key
|
||||
# to determine whether a record already exists. If the record exists, its attributes
|
||||
# are not overwritten; otherwise, a new record is created.
|
||||
#
|
||||
# @param model_class [Class] The ActiveRecord model class to load data for.
|
||||
# @param csv_filename [String] The CSV filename (located in db/seed/test).
|
||||
# @param unique_key [Symbol] The attribute used to uniquely identify a record (default: :granblue_id).
|
||||
# @param use_id [Boolean] If true, preserves the CSV id field instead of removing it (default: false).
|
||||
#
|
||||
# @return [void]
|
||||
def load_csv_for(model_class, csv_filename, unique_key = :granblue_id, use_id: false)
|
||||
csv_file = Rails.root.join('db', 'seed', 'test', csv_filename)
|
||||
# puts "Loading #{model_class.name} data from #{csv_file}..."
|
||||
|
||||
CSV.foreach(csv_file, headers: true) do |row|
|
||||
# Convert CSV row to a hash with symbolized keys.
|
||||
attrs = row.to_hash.symbolize_keys
|
||||
|
||||
# Process known boolean columns.
|
||||
process_booleans(attrs, %i[flb ulb subaura limit transcendence])
|
||||
# Process known date columns. Extend this list as needed.
|
||||
process_dates(attrs, %i[release_date flb_date ulb_date transcendence_date created_at])
|
||||
|
||||
# Clean up attribute values: trim whitespace and convert empty strings to nil.
|
||||
attrs.each { |k, v| attrs[k] = nil if v.is_a?(String) && v.strip.empty? }
|
||||
|
||||
# Remove the :id attribute unless we want to preserve it (for fixed canonical IDs).
|
||||
attrs.except!(:id) unless use_id
|
||||
|
||||
# Find or create the record based on the unique key.
|
||||
record = model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
|
||||
# Assign all attributes except the unique_key.
|
||||
r.assign_attributes(attrs.except(unique_key))
|
||||
end
|
||||
|
||||
# puts "Loaded #{model_class.name}: #{record.public_send(unique_key)}"
|
||||
end
|
||||
end
|
||||
|
||||
# Load canonical data for core models.
|
||||
load_csv_for(Awakening, 'awakenings_test.csv', :id, use_id: true)
|
||||
load_csv_for(Summon, 'summons_test.csv', :id, use_id: true)
|
||||
load_csv_for(Weapon, 'weapons_test.csv', :id, use_id: true)
|
||||
load_csv_for(Character, 'characters_test.csv', :id, use_id: true)
|
||||
|
||||
# Load additional canonical data that require preserving the provided IDs.
|
||||
load_csv_for(Job, 'jobs_test.csv', :id, use_id: true)
|
||||
load_csv_for(Guidebook, 'guidebooks_test.csv', :id, use_id: true)
|
||||
load_csv_for(JobAccessory, 'job_accessories_test.csv', :id, use_id: true)
|
||||
load_csv_for(JobSkill, 'job_skills_test.csv', :id, use_id: true)
|
||||
load_csv_for(WeaponAwakening, 'weapon_awakenings_test.csv', :id, use_id: true)
|
||||
load_csv_for(WeaponKey, 'weapon_keys_test.csv', :id, use_id: true)
|
||||
11
db/seed/test/awakenings_test.csv
Normal file
11
db/seed/test/awakenings_test.csv
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"id","name_en","name_jp","slug","object_type","order"
|
||||
"6e233877-8cda-4c8f-a091-3db6f68749e2","Attack","攻撃","character-atk","Character",1
|
||||
"b1847c82-ece0-4d7a-8af1-c7868d90f34a","Balanced","バランス","character-balanced","Character",0
|
||||
"c95441de-f949-4a62-b02b-101aa2e0a638","Defense","防御","character-def","Character",2
|
||||
"e36b0573-79c3-4dd2-9524-c95def4bbb1a","Multiattack","連続攻撃","character-multi","Character",3
|
||||
"d691a61c-dc7e-4d92-a8e6-98c04608353c","Attack","攻撃","weapon-atk","Weapon",1
|
||||
"a60b8356-ec37-4f8b-a188-a3d48803ac76","C.A.","奥義","weapon-ca","Weapon",4
|
||||
"969d37db-5f14-4d1a-bef4-59ba5a016674","Defense","防御","weapon-def","Weapon",2
|
||||
"26a47007-8886-476a-b6c0-b56c8fcdb09f","Healing","回復","weapon-heal","Weapon",5
|
||||
"18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7","Skill DMG","アビダメ","weapon-skill","Weapon",6
|
||||
"275c9de5-db1e-4c66-8210-660505fd1af4","Special","特殊","weapon-special","Weapon",3
|
||||
|
17
db/seed/test/characters_test.csv
Normal file
17
db/seed/test/characters_test.csv
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"id","name_en","name_jp","granblue_id","release_date","wiki_en","wiki_ja","rarity","element","proficiency1","proficiency2","gender","race1","race2","flb","min_hp","max_hp","max_hp_flb","min_atk","max_atk","max_atk_flb","base_da","base_ta","ougi_ratio","ougi_ratio_flb","special","ulb","max_hp_ulb","max_atk_ulb","character_id","nicknames_en","nicknames_jp","flb_date","ulb_date","gamewith","kamigame"
|
||||
"9ad10c6f-83cd-4de3-a1ec-829efe0ac83b","Rosamia (SSR)","ロザミア(SSR)","3040087000","2016-06-30","Rosamia (SSR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SSR%29",3,6,1,,2,1,,FALSE,300,1600,,300,1600,,10,5,4.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"33985","SSR/ロザミア"
|
||||
"afd282c7-ba4d-4213-b039-4ae7b71ef26e","Rosamia","ロザミア","3020018000","2014-03-10","Rosamia","%A5%ED%A5%B6%A5%DF%A5%A2%20%28R%29",1,6,1,,2,1,,FALSE,210,840,,950,3800,,,,,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21155","R/ロザミア(R)"
|
||||
"d5fb1b79-483f-44cf-8437-92ce31f5f2b2","Rosamia (SR)","ロザミア(SR)","3030049000","2014-12-31","Rosamia (SR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SR%29",2,6,1,,2,1,,FALSE,260,1300,,260,1300,,7,3,3.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21090","SR/ロザミア"
|
||||
"24bf1c09-509f-4db1-b953-b95ebcc69fb9","Seofon","シエテ","3040036000","2015-04-16","Seofon","%A5%B7%A5%A8%A5%C6%20%28SSR%29",3,1,1,,1,1,,TRUE,237,1477,1777,1777,10777,12777,10,5,4.5,5,FALSE,TRUE,14777,1977,"{4007}","{siete}","{}","2017-03-20","2021-06-29","21117","SSR/シエテ"
|
||||
"b1eae4fe-e35c-44da-aa4f-7ca1c3e5863f","Zeta","ゼタ","3040028000","2014-12-31","Zeta","%A5%BC%A5%BF%20%28SSR%29",3,2,4,,2,1,,TRUE,240,1280,1520,240,1280,1520,10,5,5,,FALSE,FALSE,,,"{3024}","{}","{}","2020-01-28",,"21290","SSR/ゼタ"
|
||||
"03b0bee9-e75b-4d5f-9ffc-0ad8cc2df7f0","Percival (Grand)","パーシヴァル(リミテッドver)","3040425000","2022-09-30","Percival (Grand)","%A5%D1%A1%BC%A5%B7%A5%F4%A5%A1%A5%EB%20%28SSR%29%A5%EA%A5%DF%A5%C6%A5%C3%A5%C9%A5%D0%A1%BC%A5%B8%A5%E7%A5%F3",3,2,1,,1,1,,FALSE,230,1340,,2010,10120,,,,,,FALSE,FALSE,,,"{3042}","{}","{}",,,"366171","SSR/リミテッドパーシヴァル"
|
||||
"c96d4ba1-8a99-49fd-acde-d8eec45f6161","Fenie (Grand)","フェニー(リミテッドver)","3040519000","2024-03-15","Fenie (Grand)","",3,2,6,,2,0,,FALSE,294,1724,,1380,6580,,,,,,FALSE,FALSE,,,"{3246}","{}","{}",,,"",""
|
||||
"3e7c163c-c92f-404e-8ec1-fe73bce6d6c3","Alanaan","アラナン","3040167000","2019-03-10","Alanaan","%A5%A2%A5%E9%A5%CA%A5%F3%20%28SSR%29",3,2,6,,1,2,,TRUE,219,1319,1519,1605,9705,11305,10,5,4.5,,FALSE,FALSE,,,"{3106}","{}","{}",,,"144742","SSR/アラナン"
|
||||
"427f3e8a-8148-4b76-8982-f6a625a0f1e6","Zeta (Grand)","ゼタ(リミテッドver)","3040499000","2023-12-28","Zeta (Grand)","",3,2,4,,2,1,,FALSE,,1100,,,10500,,,,,,FALSE,FALSE,,,"{3024}","{}","{}",,,"",""
|
||||
"437ddfde-7c39-469f-b75e-102f30595880","Fraux","フラウ","3040161000","2019-03-10","Fraux","%A5%D5%A5%E9%A5%A6%20%28SSR%29",3,2,7,,2,2,,TRUE,215,1315,,1608,9808,,10,5,4.5,,FALSE,FALSE,,,"{3100}","{}","{}","2023-08-16",,"144749","SSR/フラウ"
|
||||
"76fe3ab2-e192-42f5-b063-920a2e406fb4","Michael","ミカエル","3040440000","2022-12-31","Michael","%A5%DF%A5%AB%A5%A8%A5%EB%20%28SSR%29",3,2,1,,2,5,,FALSE,240,1256,,2200,11320,,,,,,FALSE,FALSE,,,"{3217}","{}","{}",,,"381021","SSR/ミカエル"
|
||||
"336f11a7-35b7-4a69-8041-c747a0c10b53","Fediel","フェディエル(リミテッドver)","3040376000","2021-12-31","Fediel","%A5%D5%A5%A7%A5%C7%A5%A3%A5%A8%A5%EB%20%28SSR%29",3,5,1,7,0,3,,FALSE,224,1200,,2015,10720,,,,,,FALSE,FALSE,,,"{3191}","{}","{}",,,"311659","SSR/フェディエル"
|
||||
"180527e3-58ad-4e90-91ed-c70fa91798f7","Tikoh","ティコ","3040337000","2021-05-18","Tikoh","%A5%C6%A5%A3%A5%B3%20%28SSR%29",3,6,6,,2,2,,FALSE,367,1794,,730,4710,,,,,,FALSE,FALSE,,,"{3179}","{}","{}",,,"277461","SSR/ティコ"
|
||||
"83ef5ef3-5180-465b-981e-6a121894aaec","Halluel and Malluel","ハールート・マールート(リミテッドver)","3040443000","2023-01-19","Halluel and Malluel","%A5%CF%A1%BC%A5%EB%A1%BC%A5%C8%A1%A6%A5%DE%A1%BC%A5%EB%A1%BC%A5%C8%20%28SSR%29",3,5,2,,2,5,,FALSE,290,800,,1500,4400,,,,,,FALSE,FALSE,,,"{3138}","{}","{}",,,"384939","SSR/リミテッドハールートマールート"
|
||||
"8dbebe0d-12ed-4334-b3d7-8f516b8b2e23","Lich","リッチ(リミテッドver)","3040357000","2021-09-15","Lich","%A5%EA%A5%C3%A5%C1%20%28SSR%29",3,5,6,,2,5,,FALSE,260,1300,,1550,8600,,,,,,FALSE,FALSE,,,"{3184}","{}","{}",,,"294327","SSR/リッチ"
|
||||
"e9bb4639-d4f2-4299-b3ed-d396760a30eb","Nier","ニーア","3040169000","2019-03-10","Nier","%A5%CB%A1%BC%A5%A2%20%28SSR%29",3,5,3,2,2,2,,TRUE,200,1313,1513,1476,8906,10306,7,3,4.5,,FALSE,FALSE,,,"{3108}","{}","{}","2023-06-07",,"144747","SSR/ニーア"
|
||||
|
9
db/seed/test/guidebooks_test.csv
Normal file
9
db/seed/test/guidebooks_test.csv
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"id","granblue_id","name_en","name_jp","description_en","description_jp","created_at"
|
||||
"3905ccba-fc56-44ef-890d-94b858ded339","6","Acuity's Guidebook","鋭撃の導本","Grants Stamina and more","渾身効果などを得られる","2023-04-17 23:19:19.425728"
|
||||
"4285b593-31ff-45e3-bd96-55c972199753","8","Insight's Guidebook","啓示の導本","Improves debuff resistance and success rate","弱体効果に強くなる","2023-04-17 23:19:19.425728"
|
||||
"794b2e5f-9eec-4d27-93ee-c7971eb25862","16","Shockwave's Guidebook","激震の導本","Greatly improves normal attacks","通常攻撃を大幅に強化する効果が得られる","2023-04-17 23:19:19.425728"
|
||||
"8453e4e8-1c86-4a92-a164-41762e5f5e49","5","Tenebrosity's Guidebook","暗夜の導本","Improves multiattack rate and more","連続攻撃確率アップなどの効果が得られる","2023-04-17 23:19:19.425728"
|
||||
"a35af3f7-3e37-46f5-9fef-615819b8492b","4","Valor's Guidebook","勇気の導本","Grant Bonus DMG effect and more","追撃などの効果が得られる","2023-04-17 23:19:19.425728"
|
||||
"a9313de5-092c-4f72-a5bb-e7f09f550961","7","Fortitude's Guidebook","守護者の導本","Greatly improves survivability","耐久効果を多く得られる","2023-04-17 23:19:19.425728"
|
||||
"af73e2ad-aae4-47dc-8f4e-c9c0d4225a84","15","Sanctum's Guidebook","聖域の導本","Greatly improves battle longevity","継戦能力が大きく高まる効果を得られる","2023-04-17 23:19:19.425728"
|
||||
"bbd6368d-567c-4d23-aa75-c2fe2c6818ff","10","Adept's Guidebook","魔刃の導本","Improves skills","アビリティを強化する効果が得られる","2023-04-17 23:19:19.425728"
|
||||
|
9
db/seed/test/job_accessories_test.csv
Normal file
9
db/seed/test/job_accessories_test.csv
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"id","job_id","name_en","name_jp","granblue_id","rarity","release_date","accessory_type"
|
||||
"32295cc2-c1ed-4e1b-9273-baa79262bf66","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Bahamut Mino","バハムート・ミニステル","5",0,"2022-01-25",2
|
||||
"32786311-6d8f-4b3b-99f7-7dd53343a0f3","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Yggdrasil Mino","ユグドラシル・ミニステル","4",0,"2022-01-25",2
|
||||
"824c06c8-0d4c-485a-9cc6-3e72e58a5588","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Mini Mimic","ミニック","7",0,"2022-01-25",2
|
||||
"8490d389-3f41-47f5-9ae5-c5bcf7f39965","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Lu Woh Mino","ル・オー・ミニステル","6",0,"2022-01-25",2
|
||||
"a2cf6934-deab-4082-8eb8-6ec3c9c0d53e","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Ouroboros Mino","ウロボロス・ミニステル","8",3,"2022-09-06",2
|
||||
"aee2ee5b-7847-45af-aab4-ba210a199bcb","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Leviathan Mino","リヴァイアサン・ミニステル","3",0,"2022-01-25",2
|
||||
"af013d1b-cc40-43ec-9d34-3a0ea0592e52","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Burlona","ブルロネ","1",0,"2022-01-25",2
|
||||
"dce5f041-b709-4cf4-aa71-bec44727d6ce","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Schalk","シャルク","2",0,"2022-01-25",2
|
||||
|
18
db/seed/test/job_skills_test.csv
Normal file
18
db/seed/test/job_skills_test.csv
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"id","job_id","name_en","name_jp","slug","color","main","sub","emp","order","base"
|
||||
"589d1718-887f-4837-9a7b-93e9ce33bbf3","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Aether Siphon","エーテルサクション","aether-siphon",2,TRUE,FALSE,FALSE,0,FALSE
|
||||
"b0fa1cbd-1761-49f7-b250-d601a98fddac","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Wild Magica","ワイルドマギカ","wild-magica",2,FALSE,FALSE,TRUE,1,FALSE
|
||||
"0cdd20ec-5869-4bff-8016-35a4a48e897a","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Secret Triad","シークレットトライアド","secret-triad",0,FALSE,FALSE,TRUE,2,FALSE
|
||||
"a42211a5-e7fd-4cdb-80a9-f2fb3ccce7f2","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Overtrance","オーバートランス","overtrance",0,FALSE,FALSE,TRUE,3,FALSE
|
||||
"b0a50aec-6f88-4a0f-900a-c26e84fd09c6","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Full Arsenal III","ウェポンバーストIII","full-arsenal-iii",0,TRUE,FALSE,FALSE,0,FALSE
|
||||
"fdfdee6d-6ead-4504-9add-a04776546b15","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Armor Break II","レイジIV","armor-break-ii",2,FALSE,FALSE,TRUE,1,FALSE
|
||||
"4df00bf2-aab1-4bd4-a399-fcad942c7daf","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Rage IV","アーマーブレイクII","rage-iv",0,FALSE,FALSE,TRUE,2,FALSE
|
||||
"e705ef94-4d70-4e24-b505-4a1e8b0038f0","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Rampage II","ランページII","rampage-ii",0,FALSE,FALSE,TRUE,3,FALSE
|
||||
"30df2315-457a-414c-9eef-3980b72b17c2","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Ulfhedinn","ウールヴヘジン","ulfhedinn",0,FALSE,FALSE,TRUE,4,FALSE
|
||||
"3b862283-c2b0-42ab-abf8-83f7b71d5fb5","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Ferocious Roar","フェロシティロアー","ferocious-roar",0,FALSE,FALSE,TRUE,5,FALSE
|
||||
"a1491902-838f-4e7d-8a4a-572a5653537f","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Beast Fang","ビーストファング","beast-fang",2,FALSE,FALSE,TRUE,6,FALSE
|
||||
"0d2987b1-2322-48b6-a071-e6b60699889b","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Bloodzerker","狂戦の血","bloodzerker",0,FALSE,FALSE,TRUE,7,FALSE
|
||||
"e76fbe2a-4dc7-4c29-9db7-7feee06559fb","43652444-64bb-4938-85d7-aafdfc503d66","Miserable Mist","ミゼラブルミスト","miserable-mist",1,TRUE,TRUE,FALSE,1,FALSE
|
||||
"37218a55-3335-4457-98c3-4d8367af3d7c","d27a4f29-5f0b-4bc6-b75a-1bd187e1a529","Splitting Spirit","他心陣","splitting-spirit",0,TRUE,TRUE,FALSE,2,FALSE
|
||||
"61e9a862-41f1-477a-9131-72b366c359be","2abbab55-5bf7-49f8-9ed6-1fe8a3991cca","Clarity","クリアオール","clarity",3,TRUE,TRUE,FALSE,1,FALSE
|
||||
"4a00259a-9e2b-4239-bca2-2afdc2f52be7","c128944b-cc79-45b4-bfed-17c8b68db612","Dispel","ディスペル","dispel",1,TRUE,TRUE,FALSE,1,FALSE
|
||||
"67a126d1-5515-492f-aeaf-7f88b25e2e26","667bf041-61c9-4568-bdd3-ce6e43f40603","Dark Haze","ブラックヘイズ","dark-haze",1,FALSE,FALSE,TRUE,1,FALSE
|
||||
|
15
db/seed/test/jobs_test.csv
Normal file
15
db/seed/test/jobs_test.csv
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"id","name_en","name_jp","proficiency1","proficiency2","row","master_level","order","base_job_id","granblue_id","accessory","accessory_type","ultimate_mastery"
|
||||
"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","Fighter","ファイター",1,3,"1",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100001",FALSE,0,FALSE
|
||||
"56aa0f3e-5cc1-49e7-a12d-a4d506064c9a","Warrior","ウオーリア",1,3,"2",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100001",FALSE,0,FALSE
|
||||
"6283eb60-234f-4851-8cc7-7ea36e42def2","Weapon Master","ウェポンマスター",1,3,"3",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100201",FALSE,0,FALSE
|
||||
"0e0c149d-8021-4f1e-a9d4-e2c77fd9e59a","Viking","ヴァイキング",1,3,"5",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100401",FALSE,0,FALSE
|
||||
"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","Wizard","ウィザード",6,2,"1",FALSE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130001",FALSE,0,FALSE
|
||||
"0b536736-669c-48d2-9b3c-a450f5183951","Sorcerer","ソーサラー",6,2,"2",FALSE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130001",FALSE,0,FALSE
|
||||
"667bf041-61c9-4568-bdd3-ce6e43f40603","Warlock","ウォーロック",6,2,"4",TRUE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130301",FALSE,0,TRUE
|
||||
"1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Berserker","ベルセルク",1,3,"4",TRUE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100301",FALSE,0,TRUE
|
||||
"a5d6fca3-5649-4e12-a6db-5fcec49150ee","Manadiver","マナダイバー",6,2,"5",FALSE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130401",TRUE,2,FALSE
|
||||
"43652444-64bb-4938-85d7-aafdfc503d66","Dark Fencer","ダークフェンサー",1,2,"3",FALSE,6,"21dff3a3-22bc-4863-9861-af0a1b41a5f0","150201",FALSE,0,FALSE
|
||||
"d27a4f29-5f0b-4bc6-b75a-1bd187e1a529","Mystic","賢者",6,6,"ex1",FALSE,6,"d27a4f29-5f0b-4bc6-b75a-1bd187e1a529","250201",FALSE,0,FALSE
|
||||
"2abbab55-5bf7-49f8-9ed6-1fe8a3991cca","Cleric","クレリック",6,4,"2",FALSE,3,"950f659b-0521-4a79-b578-7f3b05cb3102","120001",FALSE,0,FALSE
|
||||
"667bf041-61c9-4568-bdd3-ce6e43f40603","Warlock","ウォーロック",6,2,"4",TRUE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130301",FALSE,0,TRUE
|
||||
"c128944b-cc79-45b4-bfed-17c8b68db612","Bishop","ビショップ",6,4,"3",FALSE,3,"950f659b-0521-4a79-b578-7f3b05cb3102","120201",FALSE,0,FALSE
|
||||
|
23
db/seed/test/summons_test.csv
Normal file
23
db/seed/test/summons_test.csv
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"id","name_en","name_jp","granblue_id","rarity","element","series","flb","ulb","max_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","subaura","limit","transcendence","max_atk_xlb","max_hp_xlb","summon_id","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence_date","nicknames_en","nicknames_jp"
|
||||
"6db3991a-e72a-41fc-bad5-c7cc6e7f39ff","Orologia","オロロジャイア","2040433000",3,5,"0",TRUE,FALSE,150,145,836,1182,,310,2023,2880,,TRUE,FALSE,FALSE,,,3341,"2024-12-31","2024-12-31",,"Orologia_(Summon)","オロロジャイア (SSR)","479773","オロロジャイア",,"{}","{}"
|
||||
"eaed64ed-2b1b-4f9f-a572-2032bbff197d","Wedges of the Sky","蒼空の楔","2040430000",3,1,,FALSE,FALSE,100,109,668,,,277,1740,,,TRUE,FALSE,FALSE,,,3338,"2024-08-09",,,"Wedges_of_the_Sky","蒼空の楔 (SSR)","458492","蒼空の楔",,"{boost,dragons}","{}"
|
||||
"7d303689-3eb1-4193-80cd-e15de46af9d4","Beelzebub (Summer)","ベルゼバブ(水着ver)","2040429000",3,3,,TRUE,FALSE,150,115,793,1132,,442,2137,2985,,TRUE,FALSE,FALSE,,,3319,"2024-07-31","2024-07-31",,"Beelzebub_(Summer)","ベルゼバブ (SSR)水着","458158","水着ベルゼバブ",,"{babu,bubz}","{}"
|
||||
"d169eaba-5493-45d4-a2f8-966850d784c9","Lu Woh","ル・オー","2040409000",3,6,"12",TRUE,FALSE,100,155,880,1243,,255,1800,2573,,TRUE,FALSE,FALSE,,,,"2023-02-28",,,"Lu Woh","%A5%EB%A1%A6%A5%AA%A1%BC%20%28SSR%29","385068","SSR/ルオー",,"{}","{}"
|
||||
"42899ba7-dbf4-433f-8dd4-805010a1a627","Yatima","ヤチマ","2040417000",3,3,"0",TRUE,FALSE,150,138,1155,,,327,2870,,,TRUE,TRUE,FALSE,,,,"2022-12-31","2022-12-31",,"Yatima","%BE%A4%B4%AD%C0%D0%2F%A5%E4%A5%C1%A5%DE%20%28SSR%29","381024","SSR/ヤチマ",,"{}","{}"
|
||||
"ad9abfc0-d919-4f75-aa15-0993d20d85ba","Beelzebub","ベルゼバブ","2040408000",3,5,"0",TRUE,FALSE,150,122,761,1081,,407,2317,3272,,TRUE,FALSE,FALSE,,,,"2021-12-31","2021-12-31",,"Beelzebub","%BE%A4%B4%AD%C0%D0%2F%A5%D9%A5%EB%A5%BC%A5%D0%A5%D6%20%28SSR%29","314288","ベルゼバブ",,"{bubz,bubs}","{}"
|
||||
"0156d098-f5e7-4d72-8d14-34e6adff9280","The Moon","ザ・ムーン","2040243000",3,3,"6",TRUE,TRUE,200,110,668,952,1094,110,668,952,1094,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","The Moon (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%E0%A1%BC%A5%F3%20%28SSR%29","81835","SSR/ザ・ムーン",,"{}","{}"
|
||||
"05214d59-2765-40c3-9a1d-6c29c6bdc6d6","Colossus Omega","コロッサス・マグナ","2040034000",3,2,"2",TRUE,TRUE,200,103,648,778,921,275,1635,1965,2315,TRUE,FALSE,TRUE,2665,1064,2001,"2014-10-08","2018-03-10","2020-08-08","Colossus Omega","%BE%A4%B4%AD%C0%D0%2F%A5%B3%A5%ED%A5%C3%A5%B5%A5%B9%A1%A6%A5%DE%A5%B0%A5%CA%20%28SSR%29","21736","SSR/コロッサス・マグナ","2024-05-02","{}","{}"
|
||||
"d27d0d9a-3b38-4dcd-89a5-4016c2906249","Bahamut","バハムート","2040003000",3,5,"0",TRUE,TRUE,200,140,850,1210,1390,140,850,1210,1390,TRUE,FALSE,TRUE,,,,"2014-04-30","2017-03-10","2019-03-22","Bahamut","%BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29","21612","SSR/バハムート",,"{}","{}"
|
||||
"fbeaa551-7ea1-48f3-982f-f4569d14fb45","Agni","アグニス","2040094000",3,2,"3",TRUE,TRUE,250,127,770,1092,1253,127,770,1092,3685,TRUE,FALSE,TRUE,,,,"2015-09-30","2019-08-27","2021-03-22","Agni","%BE%A4%B4%AD%C0%D0%2F%A5%A2%A5%B0%A5%CB%A5%B9%20%28SSR%29","147663","SSR/アグニス",,"{}","{}"
|
||||
"04302038-a8dc-4860-a09a-257c0a6ac2a9","Lucifer","ルシフェル","2040056000",3,6,"0",TRUE,TRUE,250,136,900,1280,1470,136,900,1280,1470,FALSE,FALSE,TRUE,,,,"2014-12-31","2017-03-10","2019-03-22","Lucifer","%BE%A4%B4%AD%C0%D0%2F%A5%EB%A5%B7%A5%D5%A5%A7%A5%EB%20%28SSR%29","21599",,,"{}","{}"
|
||||
"49bd4739-486a-4eca-990d-88431279ac3a","Qilin","黒麒麟","2040158000",3,5,,FALSE,FALSE,100,107,649,,,107,649,,,FALSE,FALSE,FALSE,,,,"2016-06-23",,,"Qilin",,,,,"{}","{}"
|
||||
"4f6b3ccd-c906-43d6-9720-8328317cf6b2","The Sun","ザ・サン","2040244000",3,2,"6",TRUE,TRUE,200,106,664,948,1090,106,664,948,1090,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","The Sun (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%B5%A5%F3%20%28SSR%29","81834","SSR/ザ・サン",,"{}","{}"
|
||||
"5c007586-588b-4eea-9bc5-d099f94af737","Wilnas","ウィルナス","2040398000",3,2,"12",TRUE,FALSE,100,127,771,1093,,399,2349,3324,,TRUE,FALSE,FALSE,,,,"2022-01-31",,,"Wilnas","%A5%A6%A5%A3%A5%EB%A5%CA%A5%B9%20%28SSR%29","317298","SSR/ウィルナス",,"{}","{}"
|
||||
"597d6c56-73e3-424f-9971-8a237700fe08","Michael","ミカエル","2040306000",3,2,"5",TRUE,FALSE,150,129,832,1184,,359,2240,3181,,TRUE,FALSE,FALSE,,,,"2022-12-31",,,"Michael","%A5%DF%A5%AB%A5%A8%A5%EB%20%28SSR%29","381021","SSR/ミカエル",,"{}","{}"
|
||||
"ad21f1d7-2b0a-4cd6-beb7-ce624d381f36","Triple Zero","トリプルゼロ","2040425000",3,6,"0",TRUE,FALSE,150,130,1140,,,367,2947,,,TRUE,TRUE,FALSE,,,,"2023-12-31","2023-12-31",,"Triple Zero","","","",,"{}","{}"
|
||||
"1e3be2f2-803c-4cff-802d-785f3b682cfb","Hades","ハデス","2040090000",3,5,"3",TRUE,TRUE,250,123,755,1071,1229,123,755,1071,3798,TRUE,FALSE,TRUE,,,,"2015-08-31","2019-08-27","2021-03-22","Hades","%BE%A4%B4%AD%C0%D0%2F%A5%CF%A5%C7%A5%B9%20%28SSR%29","147575","SSR/ハデス",,"{}","{}"
|
||||
"d27d0d9a-3b38-4dcd-89a5-4016c2906249","Bahamut","バハムート","2040003000",3,5,"0",TRUE,TRUE,200,140,850,1210,1390,140,850,1210,1390,TRUE,FALSE,TRUE,,,,"2014-04-30","2017-03-10","2019-03-22","Bahamut","%BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29","21612","SSR/バハムート",,"{}","{}"
|
||||
"0fcfa02d-879f-4166-bc70-1f86a99a45ca","Sariel","サリエル","2040327000",3,5,"5",TRUE,FALSE,150,132,790,1119,,357,2155,3054,,TRUE,FALSE,FALSE,,,,"2019-04-30","2022-03-24",,"Sariel","%BE%A4%B4%AD%C0%D0%2F%A5%B5%A5%EA%A5%A8%A5%EB%20%28SSR%29","149228","SSR/サリエル",,"{}","{}"
|
||||
"b203a9bc-9453-4090-91cc-84532b709d58","Zirnitra","ジルニトラ","2040385000",3,5,,FALSE,FALSE,100,128,806,,,128,806,,,TRUE,FALSE,FALSE,,,,"2020-09-30",,,"Zirnitra",,"230823",,,"{}","{}"
|
||||
"83a6dfcb-0b74-4354-948c-2cff49b5b2b9","Fediel","フェディエル","2040418000",3,5,"12",TRUE,FALSE,150,132,810,1149,,374,2154,3044,,TRUE,FALSE,FALSE,,,,"2021-12-31",,,"Fediel","%A5%D5%A5%A7%A5%C7%A5%A3%A5%A8%A5%EB%20%28SSR%29","311659","SSR/フェディエル",,"{}","{}"
|
||||
"fbde7c76-be0c-4a42-8479-046ed9715db9","Death","デス","2040238000",3,5,"6",TRUE,TRUE,200,109,695,984,1128,109,695,984,1128,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","Death (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%C7%A5%B9%20%28SSR%29","81843","SSR/デス",,"{}","{}"
|
||||
|
14
db/seed/test/weapon_awakenings_test.csv
Normal file
14
db/seed/test/weapon_awakenings_test.csv
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"id","weapon_id","awakening_id"
|
||||
"3f8be70e-db9f-41c0-91a7-b07cca9ed263","706438c4-a5eb-4f7c-a145-0c2a3e7e6fbe","18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7"
|
||||
"59af97e7-8828-432e-9ff7-b2c792d08d70","ba7af3b3-c62f-4f85-a420-0321c776ba00","275c9de5-db1e-4c66-8210-660505fd1af4"
|
||||
"97c4b396-597f-4622-9f6d-ee9536a6629b","ba7af3b3-c62f-4f85-a420-0321c776ba00","d691a61c-dc7e-4d92-a8e6-98c04608353c"
|
||||
"b6b911bb-ee89-435f-b325-9df53a1ce6ea","ba7af3b3-c62f-4f85-a420-0321c776ba00","969d37db-5f14-4d1a-bef4-59ba5a016674"
|
||||
"1dbff135-b401-4619-973d-740f4504ee3a","a2f0db22-baf1-4640-8c2e-6d283375744f","d691a61c-dc7e-4d92-a8e6-98c04608353c"
|
||||
"d48fd874-484d-41c5-bff0-709cb714f7b0","a2f0db22-baf1-4640-8c2e-6d283375744f","275c9de5-db1e-4c66-8210-660505fd1af4"
|
||||
"e793cc76-025d-4b6d-975a-58c56ff19141","47208685-e87a-4e07-b328-fb9ac3888718","d691a61c-dc7e-4d92-a8e6-98c04608353c"
|
||||
"42ba1467-971e-40bd-b701-07538678cc95","e7a05d2e-a3ec-4620-98a5-d8472d474971","d691a61c-dc7e-4d92-a8e6-98c04608353c"
|
||||
"6e94080f-1bbf-4171-8d77-40328c1daf1f","e7a05d2e-a3ec-4620-98a5-d8472d474971","969d37db-5f14-4d1a-bef4-59ba5a016674"
|
||||
"714e3575-d536-4a77-870b-b5e2d8b31b68","e7a05d2e-a3ec-4620-98a5-d8472d474971","275c9de5-db1e-4c66-8210-660505fd1af4"
|
||||
"5daffb43-f456-41db-8e04-dadc42bea788","8137294e-6bf1-4bac-a1e0-38cdc542622b","d691a61c-dc7e-4d92-a8e6-98c04608353c"
|
||||
"ab83344b-b4ee-4aad-8e9b-1b7a8169575b","8137294e-6bf1-4bac-a1e0-38cdc542622b","275c9de5-db1e-4c66-8210-660505fd1af4"
|
||||
"e26dbd37-b4d1-49f2-a5f2-36525a57b998","8137294e-6bf1-4bac-a1e0-38cdc542622b","969d37db-5f14-4d1a-bef4-59ba5a016674"
|
||||
|
47
db/seed/test/weapon_keys_test.csv
Normal file
47
db/seed/test/weapon_keys_test.csv
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"id","name_en","name_jp","slot","group","order","slug","granblue_id","series"
|
||||
"02b40c48-b0d4-4df6-a27f-da2bc58fdd0f","Pendulum of Strife","闘争のペンデュラム",1,0,2,"pendulum-strife",14003,"{3}"
|
||||
"0946e421-db65-403b-946f-5e2285e963f5","Pendulum of Sagacity","窮理のペンデュラム",1,2,2,"pendulum-sagacity",14006,"{3}"
|
||||
"14534be3-defa-44cd-9096-09bae07565c8","Chain of Temperament","技錬のチェイン",1,1,0,"chain-temperament",14011,"{3}"
|
||||
"1e2a1e5b-75f4-4e00-85d5-e5ef474dd6d7","Chain of Depravity","邪罪のチェイン",1,1,5,"chain-depravity",14016,"{3}"
|
||||
"3faafaf1-5fc5-4aa8-8c65-894bbe1c615f","α Pendulum","アルファ・ペンデュラム",0,0,0,"pendulum-alpha",13001,"{3}"
|
||||
"562c89bd-68cf-4a33-8609-d82e017130d6","Chain of Restoration","賦活のチェイン",1,1,1,"chain-restoration",14012,"{3}"
|
||||
"5936e870-61a1-40a4-8c52-b85b9ab96967","Pendulum of Prosperity","隆盛のペンデュラム",1,0,3,"pendulum-prosperity",14004,"{3}"
|
||||
"653477b7-5321-4ea4-8b6f-42218e67a090","Pendulum of Zeal","激情のペンデュラム",1,0,1,"pendulum-zeal",14002,"{3}"
|
||||
"6ded911e-81d6-4fae-a3e7-682a5d18f2fc","Chain of Glorification","謳歌のチェイン",1,1,2,"chain-glorification",14013,"{3}"
|
||||
"b3d8d4d8-8bf6-4e03-9f21-547653bf7574","Pendulum of Strength","強壮のペンデュラム",1,0,0,"pendulum-strength",14001,"{3}"
|
||||
"c7a65d1f-c6a5-4c12-a90e-f3a31dc9d8f9","Pendulum of Extremity","絶涯のペンデュラム",1,2,1,"pendulum-extremity",14005,"{3}"
|
||||
"d5b81056-fd58-45b6-b6ef-a43b45a15194","Chain of Temptation","誘惑のチェイン",1,1,3,"chain-temptation",14014,"{3}"
|
||||
"d5ed9765-263e-4e28-b46a-a1f6bf8c6615","Pendulum of Supremacy","天髄のペンデュラム",1,2,3,"pendulum-supremacy",14007,"{3}"
|
||||
"e719de37-500e-44cd-98a4-2d9af71e0809","Δ Pendulum","デルタ・ペンデュラム",0,0,4,"pendulum-delta",13004,"{3}"
|
||||
"ebe424a0-7370-4b07-bd37-7eeee9b8425c","Chain of Falsehood","虚詐のチェイン",1,1,6,"chain-falsehood",14017,"{3}"
|
||||
"ed19dcef-8579-4125-8607-5a43922d0999","β Pendulum","ベータ・ペンデュラム",0,0,1,"pendulum-beta",13002,"{3}"
|
||||
"f5d711d8-f2f8-4909-9a64-ce6dc3584e03","γ Pendulum","ガンマ・ペンデュラム",0,0,2,"pendulum-gamma",13003,"{3}"
|
||||
"f81ec8e8-acc8-4ad3-8460-b628e90cd29d","Chain of Forbiddance","禁忌のチェイン",1,1,4,"chain-forbiddance",14015,"{3}"
|
||||
"0b696acb-baf4-4ad8-9caa-4255b338b13b","Gauph Key of Vitality","ガフスキー【生命】",0,3,2,"gauph-vitality",10003,"{13}"
|
||||
"148e3323-395f-417c-b18a-96fd9421cfe6","Gauph Key of Strife","ガフスキー【闘争】",0,3,1,"gauph-strife",10002,"{13}"
|
||||
"2ebe966e-0339-4464-acb9-0db138c3e2e7","Gauph Key of Will","ガフスキー【戦意】",0,3,0,"gauph-will",10001,"{13}"
|
||||
"3ca1a71c-66bf-464a-8ad2-254c52169e8e","Gauph Key γ","ガフスキー【γ】",1,3,2,"gauph-gamma",11003,"{13}"
|
||||
"3d5d610a-3734-444d-8818-fce2024a190b","Gauph Key Tria","ガフスキー【トリア】",2,3,2,"gauph-tria",17003,"{13}"
|
||||
"4d6fefb6-09e6-4c92-98b0-a48b35ddd738","Gauph Key β","ガフスキー【β】",1,3,1,"gauph-beta",11002,"{13}"
|
||||
"606632e3-3391-4223-8147-07060fe6f2e4","Gauph Key of Courage","ガフスキー【勇気】",0,3,5,"gauph-courage",10006,"{13}"
|
||||
"6d03b9c2-08d8-49ea-8522-5507e9243ccc","Gauph Key α","ガフスキー【α】",1,3,0,"gauph-alpha",11001,"{13}"
|
||||
"98a358bc-d123-40c9-8c0e-7953467c9a27","Gauph Key Δ","ガフスキー【Δ】",1,3,3,"gauph-delta",11004,"{13}"
|
||||
"a1613dcd-dcc1-4290-95e7-3f9dfc28dd06","Gauph Key Tessera","ガフスキー【テーセラ】",2,3,3,"gauph-tessera",17004,"{13}"
|
||||
"abd48244-8398-4159-ada6-9062803189f1","Gauph Key of Strength","ガフスキー【強壮】",0,3,3,"gauph-strength",10004,"{13}"
|
||||
"cdd87f62-2d29-4698-b09d-8eef3f7b4406","Gauph Key Ena","ガフスキー【エナ】",2,3,0,"gauph-ena",17001,"{13}"
|
||||
"d0dd2b46-cb55-4c2f-beb6-e2ee380bdb5e","Gauph Key Dio","ガフスキー【ディオ】",2,3,1,"gauph-dio",17002,"{13}"
|
||||
"d6c0afdb-f6f3-4473-ada3-d505228ee348","Gauph Key of Zeal","ガフスキー【激情】",0,3,4,"gauph-zeal",10005,"{13}"
|
||||
"44c2b0ba-642e-4edc-9680-1a34abe20418","Emblem of Devilry","魔獄のエンブレム",0,4,2,"emblem-devilry",3,"{19}"
|
||||
"5ac2ad0a-f8da-403a-b098-7831d354f8e0","Emblem of Divinity","天聖のエンブレム",0,4,1,"emblem-divinity",2,"{19}"
|
||||
"c2f1e5bc-9f8b-4af1-821c-2b32a9fb5f1f","Emblem of Humanity","英勇のエンブレム",0,4,0,"emblem-humanity",1,"{19}"
|
||||
"0c6ce91c-864c-4c62-8c9b-be61e8fae47f","Optimus Teluma","オプティマス・テルマ",1,2,0,"teluma-optimus",16001,"{27,40}"
|
||||
"1929bfa8-6bbd-4918-9ad7-594525b5e2c6","Crag Teluma","巨岩のテルマ",0,2,3,"teluma-crag",15004,"{27,40}"
|
||||
"3fa65774-1ed1-4a16-86cd-9133adca2232","Omega Teluma","マグナ・テルマ",1,2,1,"teluma-omega",16002,"{27,40}"
|
||||
"49f46e22-1796-435e-bce2-d9fdfe76d6c5","Tempest Teluma","暴風のテルマ",0,2,4,"teluma-tempest",15005,"{27,40}"
|
||||
"81950efb-a4e1-4d45-8572-ddb604246212","Malice Teluma","闇禍のテルマ",0,2,6,"teluma-malice",15007,"{27,40}"
|
||||
"d14e933e-630d-4cd6-9d61-dbdfd6e9332e","Abyss Teluma","深海のテルマ",0,2,2,"teluma-abyss",15003,"{27,40}"
|
||||
"dc96edb7-8bee-4721-94c2-daa6508aaed8","Inferno Teluma","炎獄のテルマ",0,2,1,"teluma-inferno",15002,"{27,40}"
|
||||
"e36950be-1ea9-4642-af94-164187e38e6c","Aureole Teluma","後光のテルマ",0,2,5,"teluma-aureole",15006,"{27,40}"
|
||||
"ee80ff09-71c0-48bb-90ff-45e138df7481","Endurance Teluma","剛堅のテルマ",0,2,0,"teluma-endurance",15001,"{27,40}"
|
||||
"b0b6d3be-7203-437e-8acd-2a59c2b5506a","Oblivion Teluma","冥烈のテルマ",0,2,8,"teluma-oblivion",15009,"{40}"
|
||||
"d79558df-53fb-4c24-963b-e0b67040afc7","Salvation Teluma","燦護のテルマ",0,2,7,"teluma-salvation",15008,"{40}"
|
||||
|
24
db/seed/test/weapons_test.csv
Normal file
24
db/seed/test/weapons_test.csv
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"id","name_en","name_jp","granblue_id","rarity","element","proficiency","series","flb","ulb","max_level","max_skill_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","extra","ax_type","limit","ax","nicknames_en","nicknames_jp","max_awakening_level","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence","transcendence_date","recruits"
|
||||
"6c4f29c8-f43b-43f1-9fc5-967fb85c816e","Gauntlet of Proudearth","揺るがぬ大地の拳","1040611300",3,4,7,,TRUE,FALSE,150,15,35,240,290,,390,2300,2780,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-04-24","2019-04-24",,"Gauntlet of Proudearth",,,,FALSE,,
|
||||
"302ded88-b5c9-4570-b422-66fc40277c4f","Ixaba","イクサバ","1040906400",3,2,10,,TRUE,FALSE,150,15,30,195,236,,502,3000,3620,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2017-03-31","2018-05-21",,"Ixaba",,"72189","イクサバ",FALSE,,"3040115000"
|
||||
"b540fbaf-48c9-41c0-981f-05953319b409","Skeletal Eclipse","呪蝕の骸槍","1040216900",3,5,4,,TRUE,FALSE,150,15,43,280,339,,441,2547,3074,,FALSE,0,FALSE,FALSE,"{}","{}",,"2021-12-31","2021-12-31",,"Skeletal Eclipse","%C9%F0%B4%EF%2F%BC%F6%BF%AA%A4%CE%B3%BC%C1%E4%20%28SSR%29","314295","呪蝕の骸槍",FALSE,,"3040376000"
|
||||
"a2025b78-5c72-4efa-9fbf-c9fdc2aa2364","Katana of Repudiation","絶対否定の太刀","1040911000",3,5,10,,TRUE,TRUE,200,20,28,189,229,269,465,2765,3340,3915,FALSE,0,TRUE,FALSE,"{}","{}",,"2019-04-11","2019-04-11","2019-04-11","Katana of Repudiation","{{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}}","{{{link_gamewith|146896}}}","{{{link_kamigame|{{{jpname|}}}}}}",TRUE,"2024-01-15",
|
||||
"aa6f8b9b-ed78-4b1a-8693-acefd5b455fc","Scythe of Repudiation","絶対否定の大鎌","1040310600",3,2,3,,TRUE,TRUE,200,20,30,195,236,277,450,2730,3300,3870,FALSE,0,TRUE,FALSE,"{}","{}",,"2019-04-11","2019-04-11","2019-04-11","Scythe of Repudiation","{{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}}","{{{link_gamewith|146896}}}","{{{link_kamigame|{{{jpname|}}}}}}",TRUE,"2024-01-15",
|
||||
"c6e4eeaa-bd19-466e-81ea-58310ed5cf25","Draconic Blade","ドラゴニックブレイド","1040912100",3,6,10,,TRUE,TRUE,200,20,32,193,233,273,445,2744,3319,3894,FALSE,0,TRUE,FALSE,"{}","{}",,"2020-03-10","2020-03-10","2020-03-10","Draconic Blade","%C9%F0%B4%EF%2F%A5%C9%A5%E9%A5%B4%A5%CB%A5%C3%A5%AF%A5%D6%A5%EC%A5%A4%A5%C9%20%28SSR%29","190367",,FALSE,,
|
||||
"1cedbb93-79ef-41ef-915f-94961ef9eba8","Nine-Realm Harp (Awakened)","九界琴・覚醒","1040801400",3,0,8,4,FALSE,FALSE,100,10,75,275,,,380,2470,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-03-10",,,"Nine-Realm Harp (Awakened)",,,,FALSE,,
|
||||
"a5d72b41-6dea-4179-9996-36c01d2dad32","Winter's Frostnettle","冬ノ霜柱","1040111300",3,3,2,,TRUE,FALSE,150,15,21,189,228,,290,1857,2249,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-07-12","2019-07-12",,"Winter%27s Frostnettle","%C9%F0%B4%EF%2F%C5%DF%A5%CE%C1%FA%C3%EC%20%28SSR%29","158278","冬ノ霜柱",FALSE,,
|
||||
"620fbcd5-7c2e-4949-8cad-bbfb0908b00f","Ecke Sachs","エッケザックス","1040007100",3,2,1,,TRUE,FALSE,150,15,106,664,800,,278,1677,2030,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2020-04-07",,"Ecke Sachs","%C9%F0%B4%EF%2F%A5%A8%A5%C3%A5%B1%A5%B6%A5%C3%A5%AF%A5%B9%20%28SSR%29","71702","エッケザックス",FALSE,,
|
||||
"8cebe3c3-be12-4985-b45d-3e9db8204e6e","Ray of Zhuque Malus","朱雀光剣・邪","1040906700",3,2,10,,TRUE,TRUE,200,20,22,145,175,205,345,2090,2530,2970,FALSE,0,TRUE,FALSE,"{}","{}",,"2017-04-10","2017-04-10","2022-04-07","Ray of Zhuque Malus",,"75564","朱雀光剣・邪",FALSE,,
|
||||
"4380828f-1acc-46cd-b7eb-1cb8d34ca9ec","Last Storm Harp","ラストストームハープ","1040808300",3,1,8,,TRUE,FALSE,150,15,62,223,260,,337,2059,2400,,FALSE,0,FALSE,FALSE,"{}","{}",,"2018-03-10","2018-03-10",,"Last Storm Harp",,,,FALSE,,
|
||||
"ec3ba18a-9417-4ebe-a898-a74d5f15385f","Pillar of Flame","炎の柱","1040215200",3,6,4,,TRUE,FALSE,150,15,37,213,250,,341,2250,2630,,FALSE,0,FALSE,FALSE,"{}","{}",,"2020-08-31","2020-08-31",,"Pillar of Flame","%C9%F0%B4%EF%2F%B1%EA%A4%CE%C3%EC%20%28SSR%29","225789","炎の柱",FALSE,,
|
||||
"d61ee84f-4520-4064-8ff9-42a899273316","Luminiera Sword Omega","シュヴァリエソード・マグナ","1040007200",3,6,1,,TRUE,TRUE,200,20,31,195,228,244,370,2275,2660,2850,FALSE,1,FALSE,TRUE,"{}","{}",,"2014-03-10",,"2018-03-10","Luminiera Sword Omega",,,,FALSE,,
|
||||
"9f94d1e5-a117-432f-9da4-f3a5022b666d","Bow of Sigurd","シグルズの弓","1040705100",3,3,5,,TRUE,FALSE,150,15,36,214,250,,365,2311,2701,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2018-07-15",,"Bow of Sigurd",,,,FALSE,,
|
||||
"a4441a22-4704-4fbc-a543-77d3b952e921","Pain and Suffering","ペイン・アンド・ストレイン","1040314300",3,5,3,,TRUE,FALSE,150,15,50,410,500,,410,1890,2260,,FALSE,0,FALSE,FALSE,"{pns}","{}",,"2021-09-15","2021-09-15",,"Pain and Suffering","%C9%F0%B4%EF%2F%A5%DA%A5%A4%A5%F3%A1%A6%A5%A2%A5%F3%A5%C9%A1%A6%A5%B9%A5%C8%A5%EC%A5%A4%A5%F3%20%28SSR%29","294337","ペイン・アンド・ストレイン",FALSE,,"3040357000"
|
||||
"f4460b37-ab5b-4252-bd79-009a8819ee25","Eternal Signature","永遠の落款","1040116600",3,5,2,,TRUE,FALSE,150,15,40,259,,,459,2562,,,FALSE,,FALSE,FALSE,"{es,""halmal dagger""}","{}",,"2023-01-19","2023-01-19",,"Eternal Signature","%C9%F0%B4%EF%2F%B1%CA%B1%F3%A4%CE%CD%EE%B4%BE%20%28SSR%29","384946",,FALSE,,"3040443000"
|
||||
"07dd062a-640c-4f00-9943-614b9f031271","Ultima Claw","オメガクロー","1040608100",3,0,7,,TRUE,TRUE,200,20,35,277,313,349,393,2717,3066,3415,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10","2021-12-03","2021-12-03","Ultima Claw",,,,FALSE,,
|
||||
"33d75927-70e9-49ba-8494-fb67b4567540","Blutgang","ブルトガング","1040008700",3,5,1,,TRUE,FALSE,150,15,36,234,280,,480,2790,3370,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2016-04-28","2017-11-17",,"Blutgang","%C9%F0%B4%EF%2F%A5%D6%A5%EB%A5%C8%A5%AC%A5%F3%A5%B0%20%28SSR%29","71711",,FALSE,,"3040082000"
|
||||
"1b3b84fd-eefa-4845-8fd0-b4452482e716","Bab-el-Mandeb","バブ・エル・マンデブ","1040311600",3,5,3,,TRUE,FALSE,150,15,31,207,251,,503,2915,3518,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-12-28","2019-12-28",,"Bab-el-Mandeb",,,,FALSE,,"3040251000"
|
||||
"dd199867-ec7b-4067-8886-1fa02e1095b4","Celeste Zaghnal Omega","セレストザグナル・マグナ","1040301400",3,5,3,,TRUE,TRUE,200,20,24,169,198,213,405,2405,2810,3010,FALSE,1,FALSE,TRUE,"{}","{}",,"2014-03-10",,"2018-03-10","Celeste Zaghnal Omega",,"71937",,FALSE,,
|
||||
"cddf9de4-ee8f-4978-9901-0ec7f2601927","Pain of Death","ペイン・オブ・デス","1040113200",3,5,2,,TRUE,TRUE,200,20,32,218,265,312,379,2241,2707,3173,TRUE,0,FALSE,FALSE,"{}","{}",,"2020-12-04","2022-02-21","2022-12-26","Pain of Death","%C9%F0%B4%EF%2F%A5%DA%A5%A4%A5%F3%A1%A6%A5%AA%A5%D6%A1%A6%A5%C7%A5%B9%20%28SSR%29","{{{link_gamewith|220273}}}",,FALSE,,
|
||||
"38df4067-db48-4dbc-b1cf-c26e019137d8","Parazonium","パラゾニウム","1040108700",3,5,2,,TRUE,FALSE,150,15,40,259,310,,459,2652,3200,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2017-02-28","2018-02-14",,"Parazonium","%C9%F0%B4%EF%2F%A5%D1%A5%E9%A5%BE%A5%CB%A5%A6%A5%E0%20%28SSR%29","71768","パラゾニウム",FALSE,,"3040111000"
|
||||
"36959849-1ff6-4317-992e-2287b31138eb","Dagger of Bahamut Coda","バハムートダガー・フツルス","1040106700",3,5,2,,TRUE,TRUE,200,20,34,229,268,307,395,2355,2750,3145,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10",,"2021-12-03","Dagger of Bahamut Coda",,,,FALSE,,
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
name_en,name_jp,granblue_id,release_date,wiki_en,wiki_ja,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,nicknames_en,nicknames_jp,flb_date,ulb_date,gamewith,kamigame,
|
||||
,,3040093000,,,,シャノワール (SSR),,,,,,,true,260,1300,1560,1550,8700,10250,,,,,,,,,,,,2024-09-24,,,SSRシャノワール,
|
||||
,,3040093000,,,,,,,,,,,true,260,1300,1560,1550,8700,10250,,,,,,,,,,,,2024-09-24,,,SSRシャノワール,
|
||||
|
|
|
|||
|
|
|
@ -1,8 +1,8 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , , ,
|
||||
Rubea Stiria,ルベウス・スティーリア,1040713800,3,2,5,1,true,false,150,15,39,225,272,,473,2885,3488,,false,,false,false,3040566000,,2024-12-28,,,Rubea_Stiria,ルベウス・スティーリア (SSR),480642,ルベウス・スティーリア,,,false,,,,,,,,,,,,,,,
|
||||
Rubea Stiria,ルベウス・スティーリア,1040713800,3,2,5,1,true,false,150,15,39,225,272,,473,2885,3488,,false,,false,false,3040566000,,2024-12-28,2024-12-28,,Rubea_Stiria,ルベウス・スティーリア (SSR),480642,ルベウス・スティーリア,,,false,,,,,,,,,,,,,,,
|
||||
Shroudsword Verveine,秘刀ヴェルヴェーヌ,1040917000,3,5,10,36,false,false,100,10,24,158,,,485,2817,,,false,,false,false,3040567000,,2024-12-28,,,Shroudsword_Verveine,秘刀ヴェルヴェーヌ (SSR),480658,秘刀ヴェルヴェーヌ,,,false,,,,,,,,,,,,,,,
|
||||
Galgalim of Gales,天風の鋭輪,1040619500,3,1,7,1,true,false,150,15,42,270,327,,450,2599,3136,,false,,false,false,3040568000,,2024-12-31,,,Galgalim_of_Gales,天風の鋭輪 (SSR),480869,天風の鋭輪,,,false,,,,,,,,,,,,,,,
|
||||
Galgalim of Gales,天風の鋭輪,1040619500,3,1,7,1,true,false,150,15,42,270,327,,450,2599,3136,,false,,false,false,3040568000,,2024-12-31,2024-12-31,,Galgalim_of_Gales,天風の鋭輪 (SSR),480869,天風の鋭輪,,,false,,,,,,,,,,,,,,,
|
||||
Shiny Cane,ケーン・オブ・シャイニー,1040423900,3,6,6,36,false,false,100,10,51,290,,,344,2138,,,false,,false,false,3040570000,,2024-12-31,,,Shiny_Cane,ケーン・オブ・シャイニー (SSR),480882,ケーン・オブ・シャイニー,,,false,,,,,,,,,,,,,,,
|
||||
Serpentius,天干地支剣・巳之飾,1040028100,3,5,1,36,true,false,150,15,47,311,377,,364,2025,2440,,false,,false,false,3040569000,,2024-12-31,,,Serpentius,天干地支剣・巳之飾 (SSR),480877,天干地支剣・巳之飾,,,false,,,,,,,,,,,,,,,
|
||||
Serpentius,天干地支剣・巳之飾,1040028100,3,5,1,36,true,false,150,15,47,311,377,,364,2025,2440,,false,,false,false,3040569000,,2024-12-31,2024-12-31,,Serpentius,天干地支剣・巳之飾 (SSR),480877,天干地支剣・巳之飾,,,false,,,,,,,,,,,,,,,
|
||||
Scorn of the Goblin King,嘲りの鬼王槍,1040219600,3,4,4,35,false,false,100,10,25,199,,,337,1884,,,false,,false,false,,,2024-12-29,,,Scorn_of_the_Goblin_King,嘲りの鬼王槍 (SSR),480681,嘲りの鬼王槍,,,false,,,,,,,,,,,,,,,
|
||||
Stone Ilhoon,ストーン・イルウーン,1030010400,2,4,1,35,false,false,75,10,15,98,,,236,1463,,,false,,false,false,,,2024-12-29,,,Stone_Ilhoon,ストーン・イルウーン (SR),,ストーン・イルウーン,,,false,,,,,,,,,,,,,,,
|
||||
|
|
|
|||
|
9
db/seed/updates/20250224-characters-021.csv
Normal file
9
db/seed/updates/20250224-characters-021.csv
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
|
||||
Maleagant,メレアガンス,3040572000,3,4,7,,1,0,,false,410,1970,,1100,6950,,,,,,false,false,,,{3265},Maleagant,2025-01-17,,,メレアガンス (SSR),482404,SSRメレアガンス,,
|
||||
Feena (Light),フィーナ(光属性ver),3040573000,3,6,5,,2,1,,false,176,1216,,2120,9920,,,,,,false,false,,,{2032},Feena (Light),2025-01-17,,,フィーナ (SSR)光属性,482405,SSR光フィーナ,,
|
||||
Jack Rakan,ジャック・ラカン,3040577000,3,4,7,1,1,0,,false,260,1388,,2000,10660,,7,3,,,false,false,,,{3269},Jack Rakan,2025-01-31,,,ジャック・ラカン (SSR),483351,SSRジャックラカン,,
|
||||
Setsuna Sakurazaki,桜咲刹那,3040576000,3,1,10,7,2,0,,false,243,1300,,1785,9500,,,,,,false,false,,,{3268},Setsuna Sakurazaki,2025-01-31,,,桜咲刹那 (SSR),483350,SSR桜咲刹那,,
|
||||
Negi Springfield,ネギ・スプリングフィールド,3040574000,3,1,6,7,1,1,,false,280,1550,,1600,8250,,7,3,,,false,false,,,{3266},Negi Springfield,2025-02-04,,,ネギ・スプリングフィールド (SSR),479782,SSRネギスプリングフィールド,,
|
||||
Evangeline A.K. McDowell,エヴァンジェリン・A・K・マクダウェル,3040575000,3,5,6,7,2,0,,false,220,1250,,1900,9750,,10,5,,,false,false,,,{3267},Evangeline A.K. McDowell,2025-02-10,,,エヴァンジェリン・A・K・マクダウェル (SSR),479781,SSRエヴァンジェリンAKマクダウェル,,
|
||||
Makura (Valentine),マコラ(バレンタインver),3040579000,3,6,10,,2,2,,false,214,1130,,1630,8750,,,,,,false,false,,,{3218},Makura (Valentine),2025-02-14,,,マコラ (SSR)バレンタインバージョン,485114,SSRバレンタインマコラ,,
|
||||
Lobelia (Valentine),ロベリア(バレンタインver),3040578000,3,5,6,7,1,1,,false,306,1606,,1470,7970,,7,3,,,false,false,,,{3104},Lobelia (Valentine),2025-02-14,,,ロベリア (SSR)バレンタインバージョン,485115,SSRバレンタインロベリア,,
|
||||
|
2
db/seed/updates/20250224-characters-022.csv
Normal file
2
db/seed/updates/20250224-characters-022.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
|
||||
,,3040103000,,,,,,,,true,210,1150,1360,1540,7970,9510,,,,,,,,,,,,2025-02-20,,アンスリア (SSR),45808,SSRアンスリア,,
|
||||
|
2
db/seed/updates/20250224-summons-006.csv
Normal file
2
db/seed/updates/20250224-summons-006.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,series,flb,ulb,max_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,subaura,limit,transcendence,max_atk_xlb,max_hp_xlb,summon_id,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,transcendence_date,nicknames_en,nicknames_jp
|
||||
Asuna Kagurazaka,神楽坂明日菜,2040434000,3,6,98,true,false,150,90,569,689,,245,1477,1785,,false,false,false,,,{3342},2025-02-04,2025-02-04,,Asuna Kagurazaka,神楽坂明日菜 (SSR),479783,神楽坂明日菜,,,
|
||||
|
12
db/seed/updates/20250224-weapons-021.csv
Normal file
12
db/seed/updates/20250224-weapons-021.csv
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
|
||||
Uralter Nagel,アルト・ナーゲル,1040619800,3,4,7,99,false,false,100,10,52,260,,,336,2289,,,false,,false,false,,,2025-01-17,,,Uralter Nagel,アルト・ナーゲル (SSR),482412,アルト・ナーゲル,,,,,,,,,,,,,,,,,
|
||||
Greatbow Rondor,宝弓ロンドール,1040713900,3,6,5,99,true,false,150,15,33,223,271,,442,2525,3046,,false,,false,false,,,2025-01-17,2025-01-17,,Greatbow Rondor,宝弓ロンドール (SSR),482411,宝弓ロンドール,,,,,,,,,,,,,,,,,
|
||||
Protean Folding Fan,変幻之妙扇,1040619900,3,2,7,98,false,false,100,10,27,195,,,328,1907,,,false,,false,false,,,2025-01-29,,,Protean Folding Fan,変幻之妙扇 (SSR),483280,変幻之妙扇,,,,,,,,,,,,,,,,,
|
||||
Opening Shamisen,出囃子三絃,1030804700,2,2,8,98,false,false,75,10,21,147,,,206,1220,,,false,,false,false,,,2025-01-29,,,Opening Shamisen,出囃子三絃 (SR),,出囃子三絃,,,,,,,,,,,,,,,,,
|
||||
Twinpain-Wolf Gun,双創・凱狼雷,1040517100,3,2,9,41,true,false,150,15,30,192,233,,481,2913,3521,,false,,false,false,,,2025-01-22,2025-01-22,,Twinpain-Wolf Gun,双創・凱狼雷 (SSR),482723,双創・凱狼雷,,,,,,,,,,,,,,,,,
|
||||
O Iros Meta Chilion Prosopon,千の顔を持つ英雄,1040028200,3,4,1,97,true,false,150,15,35,206,249,,424,2557,3090,,false,,false,false,,,2025-01-31,2025-01-31,,O Iros Meta Chilion Prosopon,千の顔を持つ英雄 (SSR),483716,千の顔を持つ英雄,,,,,,,,,,,,,,,,,
|
||||
Sica Shishikushiro,匕首・十六串呂,1040121200,3,1,2,97,true,false,150,15,37,235,285,,414,2412,2912,,false,,false,false,,,2025-01-31,2025-01-31,,Sica Shishikushiro,匕首・十六串呂 (SSR),483715,匕首・十六串呂,,,,,,,,,,,,,,,,,
|
||||
Negi's Staff,ネギの杖,1040424000,3,1,6,97,true,false,150,15,38,239,289,,273,1684,2037,,false,,false,false,,,2025-02-04,2025-02-04,,Negi%27s_Staff,ネギの杖 (SSR),483872,ネギの杖,,,,,,,,,,,,,,,,,
|
||||
Ensis Exorcizans,ハマノツルギ,1040028300,3,6,1,97,true,false,150,15,20,135,164,,364,2207,2668,,false,,false,false,,,2025-02-04,2025-02-04,,Ensis Exorcizans,ハマノツルギ (SSR),483873,ハマノツルギ,,,,,,,,,,,,,,,,,
|
||||
Fourth Fork of the Fluffle,第四卯行突匙,1040219700,3,4,4,99,true,false,150,15,41,248,300,,393,2346,2834,,false,,false,false,,,2025-02-14,2025-02-14,,Fourth Fork of the Fluffle,第四卯行突匙 (SSR),485117,第四卯行突匙,,,,,,,,,,,,,,,,,
|
||||
Clapotis Douleur,ドゥルール・クラポティ,1040319200,3,5,3,99,false,false,100,10,23,171,,,484,2731,,,false,,false,false,,,2025-02-14,,,Clapotis Douleur,ドゥルール・クラポティ (SSR),485128,ドゥルール・クラポティ,,,,,,,,,,,,,,,,,
|
||||
|
3
db/seed/updates/20250227-characters-023.csv
Normal file
3
db/seed/updates/20250227-characters-023.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
|
||||
Yuel (Grand),ユエル(リミテッドver),3040580000,3,2,10,8,2,2,,false,265,1375,,1413,7335,,,,,,false,false,,,{3006},Yuel (Grand),2025-02-27,,,ユエル (SSR)リミテッドバージョン,486749,SSRリミテッドユエル,,
|
||||
Tsukuyomi,ツクヨミ,3040581000,3,5,6,,2,5,,false,335,1175,,1785,6275,,,,,,false,false,,,{3270},Tsukuyomi,2025-02-27,,,ツクヨミ (SSR),486748,SSRツクヨミ,,
|
||||
|
7
db/seed/updates/20250227-weapons-022.csv
Normal file
7
db/seed/updates/20250227-weapons-022.csv
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
|
||||
Sennen Goji,千年護持,1040917100,3,2,10,1,true,false,150,15,29,244,,,519,3577,,,false,,false,false,,,2025-02-28,2025-02-28,,Sennen Goji,,486774,千年護持,,,false,,,,,,,,,,,,,,
|
||||
Nightgaze Gate,夜見之門,1040424200,3,5,6,99,false,false,100,10,46,285,,,369,2163,,,false,,false,false,,,2025-02-28,,,Nightgaze Gate,,,夜見之門,,,false,,,,,,,,,,,,,,
|
||||
Bane of Avidya,無明滅却杵,1040424100,3,6,6,98,false,false,100,10,34,293,,,227,1744,,,false,,false,false,,,2025-02-26,,,Bane of Avidya,無明滅却杵,486167,無明滅却杵,,,false,,,,,,,,,,,,,,
|
||||
"
|
||||
Klesha-Cleansing Dharmachakra",破魔輪宝,1030609800,2,6,7,98,false,false,75,10,22,136,,,197,1272,,,false,,false,false,,,2025-02-26,,,"
|
||||
Klesha-Cleansing Dharmachakra",破魔輪宝,,破魔輪宝,,,false,,,,,,,,,,,,,,
|
||||
|
9
db/seed/updates/20250228-weapons-023.csv
Normal file
9
db/seed/updates/20250228-weapons-023.csv
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
|
||||
,,1040619800,,,,,,,,,,,,,,,,,,,,,3040572000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040713900,,,,,,,,,,,,,,,,,,,,,3040573000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040028200,,,,,,,,,,,,,,,,,,,,,3040577000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040121200,,,,,,,,,,,,,,,,,,,,,3040576000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040219700,,,,,,,,,,,,,,,,,,,,,3040579000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040319200,,,,,,,,,,,,,,,,,,,,,3040578000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040917100,,,,,,,,,,,,,,,,,,,,,3040580000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040424200,,,,,,,,,,,,,,,,,,,,,3040581000,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
10
db/seed/updates/20250301-characters-024.csv
Normal file
10
db/seed/updates/20250301-characters-024.csv
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
|
||||
Kaguya (Grand),,3040486000,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,Kaguya,
|
||||
,,3040519000,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,Fenie,
|
||||
,,3040501000,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,Uriel,
|
||||
,,3040254000,,,,,,,,,,,,,,,,,,,,,,,{2030},,,,,,,,,
|
||||
,,3040158000,,,,,,,,,,,,,,,,,,,,,,,{3098},,,,,,,,,
|
||||
,,3040103000,,,,,,,,,,,,,,,,,,,,,,,{3070},,,,,,,,,
|
||||
,,3040377000,,,,,,,,,,,,,,,,,,,,,,,{3192},,,,,,,,,
|
||||
,,3040058000,,,,,,,,,,,,,,,,,,,,,,,{3045},,,,,,,,,
|
||||
,,3040500000,,,,,,,,,,,,,,,,,,,,,,,{1027},,,,,,,,,
|
||||
|
7
db/seed/updates/20250310-summons-007.csv
Normal file
7
db/seed/updates/20250310-summons-007.csv
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,series,flb,ulb,max_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,subaura,limit,transcendence,max_atk_xlb,max_hp_xlb,summon_id,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,transcendence_date,nicknames_en,nicknames_jp
|
||||
,,2040361000,,,,true,,,,,,,,,,,,,,,,,,2025-03-10,,,,,,,,
|
||||
,,2040363000,,,,true,,,,,,,,,,,,,,,,,,2025-03-10,,,,,,,,
|
||||
,,2040368000,,,,true,,,,,,,,,,,,,,,,,,2025-03-10,,,,,,,,
|
||||
,,2040366000,,,,true,,,,,,,,,,,,,,,,,,2025-03-10,,,,,,,,
|
||||
,,2040381000,,,,true,,,,,,,,,,,,,,,,,,2025-03-10,,,,,,,,
|
||||
,,2040385000,,,,true,,,,,,,,,,,,,,,,,,2025-03-10,,,,,,,,
|
||||
|
5
db/seed/updates/20250318-characters-025.csv
Normal file
5
db/seed/updates/20250318-characters-025.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
|
||||
Basara (Grand),バサラ (リミテッドver),3040582000,3,6,10,7,1,2,,false,192,1128,,1740,8760,,,,,,false,false,,,{3271},Basara,2025-03-17,,,バサラ (SSR)リミテッドバージョン,489323,SSRバサラ,,
|
||||
Mahira (Summer),マキラ(水着ver),3040584000,3,1,8,7,2,3,,false,281,1217,,1408,8915,,,,,,false,false,,,{3073},Mahira (Summer),2025-03-17,,,マキラ (SSR)水着バージョン,489328,SSR水着マキラ,,
|
||||
Lu Woh (Summer),ル・オー(水着ver),3040583000,3,4,7,6,0,2,,false,236,1172,,1385,7820,,,,,,false,false,,,{3221},Lu Woh (Summer),2025-03-17,,,ル・オー (SSR)水着バージョン,490119,SSR水着ルオー,,
|
||||
Joy (Event SSR),ジョイ(イベントSSR),3040588000,3,1,7,5,0,0,,false,108,1080,,1711,9011,,,,,,false,false,,,{2146},Joy (Event SSR),2025-03-11,,,ジョイ (SSR),489593,SSRジョイ,,
|
||||
|
8
db/seed/updates/20250327-weapons-024.csv
Normal file
8
db/seed/updates/20250327-weapons-024.csv
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
|
||||
Canifortis,天干地支刀・戌之威,1040917200,3,6,10,2,true,false,150,15,38,215,259,,474,2895,3500,,false,,false,false,3040582000,,2025-03-17,2025-03-17,,Canifortis,天干地支刀・戌之威 (SSR),490121,天干地支刀・戌之威,,,false,,,,,,,,,,,,,,
|
||||
Tenth Crow of the Clutch,第十酉行筒,1040517200,3,1,9,99,false,false,100,10,33,196,,,434,2606,,,false,,false,false,3040584000,,2025-03-17,,,Tenth Crow of the Clutch,第十酉行筒 (SSR),490124,第十酉行筒,,,false,,,,,,,,,,,,,,
|
||||
Lu Woh Float,ル・オー・フロート,1040817100,3,4,8,99,false,false,100,10,51,303,,,344,2073,,,false,,false,false,3040583000,,2025-03-17,,,Lu Woh Float,ル・オー・フロート (SSR),490125,ル・オー・フロート,,,false,,,,,,,,,,,,,,
|
||||
Exo Heliocentrum,神杖エクス・ヘリオセント,1040424300,3,6,6,39,true,false,150,15,41,260,315,,333,2029,2453,,false,,false,false,,,2025-03-22,2025-03-22,,Exo Heliocentrum,神杖エクス・ヘリオセント (SSR),490430,神杖エクス・ヘリオセント,,,false,,,,,,,,,,,,,,
|
||||
Onmyoji's Reito,陰陽之霊刀,1040917300,3,0,10,19,true,true,200,20,35,,,281,430,,,3856,false,,false,false,,,2025-03-25,2025-03-25,2025-03-25,Onmyoji%27s_Reito,陰陽之霊刀 (SSR),490757,陰陽之霊刀,,,false,,,,,,,,,,,,,,
|
||||
Ouranosphaira Ravdos,ウラニアスフェラ・ラヴドス,1040424400,3,0,6,19,true,true,200,20,44,,,371,379,,,3390,false,,false,false,,,2025-03-25,2025-03-25,2025-03-25,Ouranosphaira Ravdos,ウラニアスフェラ・ラヴドス (SSR),490756,ウラニアスフェラ・ラヴドス,,,false,,,,,,,,,,,,,,
|
||||
Cometa Sica,コメーテス・シーカ,1040121300,3,0,2,19,true,true,200,20,43,,,397,384,,,3251,false,,false,false,,,2025-03-25,2025-03-25,2025-03-25,Cometa Sica,コメーテス・シーカ (SSR),490753,コメーテス・シーカ,,,false,,,,,,,,,,,,,,
|
||||
|
16
db/seed/updates/20250327-weapons-025.csv
Normal file
16
db/seed/updates/20250327-weapons-025.csv
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
|
||||
,,1040022600,,,,,true,,150,15,,,221,,,,2603,,,,,,,,,2025-03-14,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040216800,,,,,true,,150,15,,,264,,,,2380,,,,,,,,,2025-03-14,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040618900,,,,,true,,150,15,,,297,,,,2854,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040713700,,,,,true,,150,15,,,270,,,,3048,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040423400,,,,,true,,150,15,,,354,,,,2568,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040916600,,,,,true,,150,15,,,247,,,,3125,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040119100,,,,,true,,150,15,,,278,,,,2943,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040518000,,,,,true,,150,15,,,213,,,,3267,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040617200,,,,,true,,150,15,,,302,,,,2828,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040916400,,,,,true,,150,15,,,214,,,,3288,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040119600,,,,,true,,150,15,,,291,,,,2879,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040816900,,,,,true,,150,15,,,335,,,,2659,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040026000,,,,,true,,150,15,,,302,,,,2821,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
,,1040816800,,,,,true,,150,15,,,366,,,,2507,,,,,,,,,2025-03-10,,,,,,,,,,,,,,,,,,,,,,
|
||||
Decorus Sicarius,,1040121100,,,,,,,,,,,,,,,,,,,,,,,,,,Decorus Sicarius,,,,,,,,,,,,,,,,,,,,
|
||||
|
4
db/seed/updates/20250330-characters-026.csv
Normal file
4
db/seed/updates/20250330-characters-026.csv
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
|
||||
Seofon (Yukata),シエテ(浴衣ver),3040586000,3,5,1,10,1,1,,false,277,1277,,1777,9777,,,,,,false,false,,,{4007},Seofon (Yukata),2025-03-30,,,シエテ (SSR)浴衣バージョン,489325,SSR浴衣シエテ,,
|
||||
Vikala (Yukata),ビカラ(浴衣ver),3040585000,3,6,3,7,2,1,,false,280,1550,,1600,8250,,,,,,false,false,,,{3150},Vikala (Yukata),2025-03-30,,,ビカラ (SSR)浴衣バージョン,491855,SSR浴衣ビカラ,,
|
||||
,,3040087000,,,,,,,,true,300,1600,1900,1500,8000,9500,,,,,,,,,,,2016-06-30,2025-03-29,,ロザミア (SSR),33985,SSRロザミア,,
|
||||
|
5
db/seed/updates/20250330-weapons-026.csv
Normal file
5
db/seed/updates/20250330-weapons-026.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
|
||||
First Fling of the Mischief,第一子行弾弓,1040714000,3,6,5,99,true,false,150,15,46,236,284,,377,2460,2981,,false,,false,false,,,2025-03-30,2025-03-30,,First Fling of the Mischief,第一子行弾弓 (SSR),,第一子行弾弓,,,false,,,,,,,,,,,,,,
|
||||
Prismatic Trientalis,七彩華刀,1040917900,3,5,10,99,false,false,100,10,18,174,,,515,2737,,,false,,false,false,,,2025-03-30,,,Prismatic Trientalis,七彩華刀 (SSR),,七彩華刀,,,false,,,,,,,,,,,,,,
|
||||
Scarface,スカーフェイス,1040517300,3,5,9,98,false,false,100,10,11,132,,,408,2221,,,false,,false,false,,,2025-03-29,,,Scarface,スカーフェイス (SSR),491802,スカーフェイス,,,false,,,,,,,,,,,,,,
|
||||
Henchman,ヘンチマン,1030109100,2,5,2,98,false,false,75,10,8,117,,,271,1368,,,false,,false,false,,,2025-03-29,,,Henchman,ヘンチマン (SR),,ヘンチマン,,,false,,,,,,,,,,,,,,
|
||||
|
|
|
@ -35,26 +35,31 @@ module Granblue
|
|||
# @param verbose [Boolean] When true, enables detailed logging
|
||||
# @param storage [Symbol] Storage mode (:local, :s3, or :both)
|
||||
# @return [void]
|
||||
def initialize(id, test_mode: false, verbose: false, storage: :both)
|
||||
def initialize(id, test_mode: false, verbose: false, storage: :both, logger: nil)
|
||||
@id = id
|
||||
@base_url = base_url
|
||||
@test_mode = test_mode
|
||||
@verbose = verbose
|
||||
@storage = storage
|
||||
@logger = logger || Logger.new($stdout) # fallback logger
|
||||
@aws_service = AwsService.new
|
||||
ensure_directories_exist unless @test_mode
|
||||
end
|
||||
|
||||
# Download images for all sizes
|
||||
# @param selected_size [String] The size to download
|
||||
# @return [void]
|
||||
def download
|
||||
log_info "-> #{@id}"
|
||||
def download(selected_size = nil)
|
||||
log_info("-> #{@id}")
|
||||
return if @test_mode
|
||||
|
||||
SIZES.each_with_index do |size, index|
|
||||
# If a specific size is provided, use only that; otherwise, use all available sizes.
|
||||
sizes = selected_size ? [selected_size] : SIZES
|
||||
|
||||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_url(size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
process_download(url, size, path, last: index == sizes.size - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -128,9 +133,9 @@ module Granblue
|
|||
download.rewind
|
||||
|
||||
# Upload to S3 if it doesn't exist
|
||||
unless @aws_service.file_exists?(s3_key)
|
||||
@aws_service.upload_stream(download, s3_key)
|
||||
end
|
||||
return if @aws_service.file_exists?(s3_key)
|
||||
|
||||
@aws_service.upload_stream(download, s3_key)
|
||||
end
|
||||
|
||||
# Check if file should be downloaded based on storage mode
|
||||
|
|
@ -182,7 +187,7 @@ module Granblue
|
|||
# Log informational message if verbose
|
||||
# @param message [String] Message
|
||||
def log_info(message)
|
||||
puts message if @verbose
|
||||
@logger.info(message) if @verbose
|
||||
end
|
||||
|
||||
# Download elemental variant image
|
||||
|
|
@ -197,12 +202,10 @@ module Granblue
|
|||
filepath = "#{path}/#{filename}"
|
||||
URI.open(url) do |file|
|
||||
content = file.read
|
||||
if content
|
||||
File.open(filepath, 'wb') do |output|
|
||||
output.write(content)
|
||||
end
|
||||
else
|
||||
raise "Failed to read content from #{url}"
|
||||
raise "Failed to read content from #{url}" unless content
|
||||
|
||||
File.open(filepath, 'wb') do |output|
|
||||
output.write(content)
|
||||
end
|
||||
end
|
||||
log_info "-> #{size}:\t#{url}..."
|
||||
|
|
|
|||
|
|
@ -15,24 +15,27 @@ module Granblue
|
|||
# Downloads images for all variants of a character based on their uncap status.
|
||||
# Overrides {BaseDownloader#download} to handle character-specific variants.
|
||||
#
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Skips download if character is not found in database
|
||||
# @note Downloads FLB/ULB variants only if character has those uncaps
|
||||
# @see #download_variants
|
||||
def download
|
||||
def download(selected_size = nil)
|
||||
character = Character.find_by(granblue_id: @id)
|
||||
return unless character
|
||||
|
||||
download_variants(character)
|
||||
download_variants(character, selected_size)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Downloads all variants of a character's images
|
||||
#
|
||||
# @param character [Character] Character model instance to download images for
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Only downloads variants that should exist based on character uncap status
|
||||
def download_variants(character)
|
||||
def download_variants(character, selected_size = nil)
|
||||
# All characters have 01 and 02 variants
|
||||
variants = %W[#{@id}_01 #{@id}_02]
|
||||
|
||||
|
|
@ -45,18 +48,22 @@ module Granblue
|
|||
log_info "Downloading character variants: #{variants.join(', ')}" if @verbose
|
||||
|
||||
variants.each do |variant_id|
|
||||
download_variant(variant_id)
|
||||
download_variant(variant_id, selected_size)
|
||||
end
|
||||
end
|
||||
|
||||
# Downloads a specific variant's images in all sizes
|
||||
#
|
||||
# @param variant_id [String] Character variant ID (e.g., "3040001000_01")
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
def download_variant(variant_id)
|
||||
def download_variant(variant_id, selected_size = nil)
|
||||
log_info "-> #{variant_id}" if @verbose
|
||||
return if @test_mode
|
||||
|
||||
SIZES.each_with_index do |size, index|
|
||||
sizes = selected_size ? [selected_size] : SIZES
|
||||
|
||||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_variant_url(variant_id, size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
|
|
@ -64,12 +71,18 @@ module Granblue
|
|||
end
|
||||
|
||||
# Builds URL for a specific variant and size
|
||||
#
|
||||
# @param variant_id [String] Character variant ID
|
||||
# @param size [String] Image size variant ("main", "grid", or "square")
|
||||
# @param size [String] Image size variant ("main", "grid", "square", or "detail")
|
||||
# @return [String] Complete URL for downloading the image
|
||||
def build_variant_url(variant_id, size)
|
||||
directory = directory_for_size(size)
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
|
||||
if size == 'detail'
|
||||
"#{@base_url}/#{directory}/#{variant_id}.png"
|
||||
else
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
end
|
||||
end
|
||||
|
||||
# Gets object type for file paths and storage keys
|
||||
|
|
@ -85,6 +98,7 @@ module Granblue
|
|||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Directory name in game asset URL structure
|
||||
# @note Maps "main" -> "f", "grid" -> "m", "square" -> "s"
|
||||
|
|
@ -93,6 +107,7 @@ module Granblue
|
|||
when 'main' then 'f'
|
||||
when 'grid' then 'm'
|
||||
when 'square' then 's'
|
||||
when 'detail' then 'detail'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,25 +15,28 @@ module Granblue
|
|||
# Downloads images for all variants of a summon based on their uncap status.
|
||||
# Overrides {BaseDownloader#download} to handle summon-specific variants.
|
||||
#
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Skips download if summon is not found in database
|
||||
# @note Downloads ULB and transcendence variants only if summon has those uncaps
|
||||
# @see #download_variants
|
||||
def download
|
||||
def download(selected_size = nil)
|
||||
summon = Summon.find_by(granblue_id: @id)
|
||||
return unless summon
|
||||
|
||||
download_variants(summon)
|
||||
download_variants(summon, selected_size)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Downloads all variants of a summon's images
|
||||
#
|
||||
# @param summon [Summon] Summon model instance to download images for
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Only downloads variants that should exist based on summon uncap status
|
||||
# @note Handles special transcendence art variants for 6★ summons
|
||||
def download_variants(summon)
|
||||
def download_variants(summon, selected_size = nil)
|
||||
# All summons have base variant
|
||||
variants = [@id]
|
||||
|
||||
|
|
@ -41,26 +44,28 @@ module Granblue
|
|||
variants << "#{@id}_02" if summon.ulb
|
||||
|
||||
# Add Transcendence variants if available
|
||||
if summon.transcendence
|
||||
variants.push("#{@id}_03", "#{@id}_04")
|
||||
end
|
||||
variants.push("#{@id}_03", "#{@id}_04") if summon.transcendence
|
||||
|
||||
log_info "Downloading summon variants: #{variants.join(', ')}" if @verbose
|
||||
|
||||
variants.each do |variant_id|
|
||||
download_variant(variant_id)
|
||||
download_variant(variant_id, selected_size)
|
||||
end
|
||||
end
|
||||
|
||||
# Downloads a specific variant's images in all sizes
|
||||
#
|
||||
# @param variant_id [String] Summon variant ID (e.g., "2040001000_02")
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Downloads all size variants (main/grid/square) for the given variant
|
||||
def download_variant(variant_id)
|
||||
def download_variant(variant_id, selected_size = nil)
|
||||
log_info "-> #{variant_id}" if @verbose
|
||||
return if @test_mode
|
||||
|
||||
SIZES.each_with_index do |size, index|
|
||||
sizes = selected_size ? [selected_size] : SIZES
|
||||
|
||||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_variant_url(variant_id, size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
|
|
@ -68,12 +73,17 @@ module Granblue
|
|||
end
|
||||
|
||||
# Builds URL for a specific variant and size
|
||||
#
|
||||
# @param variant_id [String] Summon variant ID
|
||||
# @param size [String] Image size variant ("main", "grid", or "square")
|
||||
# @param size [String] Image size variant ("main", "grid", "square", or "detail")
|
||||
# @return [String] Complete URL for downloading the image
|
||||
def build_variant_url(variant_id, size)
|
||||
directory = directory_for_size(size)
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
if size == 'detail'
|
||||
"#{@base_url}/#{directory}/#{variant_id}.png"
|
||||
else
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
end
|
||||
end
|
||||
|
||||
# Gets object type for file paths and storage keys
|
||||
|
|
@ -89,14 +99,16 @@ module Granblue
|
|||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Directory name in game asset URL structure
|
||||
# @note Maps "main" -> "party_main", "grid" -> "party_sub", "square" -> "s"
|
||||
def directory_for_size(size)
|
||||
case size.to_s
|
||||
when 'main' then 'party_main'
|
||||
when 'grid' then 'party_sub'
|
||||
when 'main' then 'ls'
|
||||
when 'grid' then 'm'
|
||||
when 'square' then 's'
|
||||
when 'detail' then 'detail'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,49 +16,54 @@ module Granblue
|
|||
# Downloads images for all variants of a weapon based on their uncap status.
|
||||
# Overrides {BaseDownloader#download} to handle weapon-specific variants.
|
||||
#
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Skips download if weapon is not found in database
|
||||
# @note Downloads transcendence variants only if weapon has those uncaps
|
||||
# @see #download_variants
|
||||
def download
|
||||
def download(selected_size = nil)
|
||||
weapon = Weapon.find_by(granblue_id: @id)
|
||||
return unless weapon
|
||||
|
||||
download_variants(weapon)
|
||||
download_variants(weapon, selected_size)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Downloads all variants of a weapon's images
|
||||
#
|
||||
# @param weapon [Weapon] Weapon model instance to download images for
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Only downloads variants that should exist based on weapon uncap status
|
||||
# @note Handles special transcendence art variants for transcendable weapons
|
||||
def download_variants(weapon)
|
||||
def download_variants(weapon, selected_size = nil)
|
||||
# All weapons have base variant
|
||||
variants = [@id]
|
||||
|
||||
# Add transcendence variants if available
|
||||
if weapon.transcendence
|
||||
variants.push("#{@id}_02", "#{@id}_03")
|
||||
end
|
||||
variants.push("#{@id}_02", "#{@id}_03") if weapon.transcendence
|
||||
|
||||
log_info "Downloading weapon variants: #{variants.join(', ')}" if @verbose
|
||||
|
||||
variants.each do |variant_id|
|
||||
download_variant(variant_id)
|
||||
download_variant(variant_id, selected_size)
|
||||
end
|
||||
end
|
||||
|
||||
# Downloads a specific variant's images in all sizes
|
||||
#
|
||||
# @param variant_id [String] Weapon variant ID (e.g., "1040001000_02")
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Downloads all size variants (main/grid/square) for the given variant
|
||||
def download_variant(variant_id)
|
||||
def download_variant(variant_id, selected_size = nil)
|
||||
log_info "-> #{variant_id}" if @verbose
|
||||
return if @test_mode
|
||||
|
||||
SIZES.each_with_index do |size, index|
|
||||
sizes = selected_size ? [selected_size] : SIZES
|
||||
|
||||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_variant_url(variant_id, size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
|
|
@ -66,12 +71,17 @@ module Granblue
|
|||
end
|
||||
|
||||
# Builds URL for a specific variant and size
|
||||
#
|
||||
# @param variant_id [String] Weapon variant ID
|
||||
# @param size [String] Image size variant ("main", "grid", or "square")
|
||||
# @param size [String] Image size variant ("main", "grid", "square", or "raw")
|
||||
# @return [String] Complete URL for downloading the image
|
||||
def build_variant_url(variant_id, size)
|
||||
directory = directory_for_size(size)
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
if size == 'raw'
|
||||
"#{@base_url}/#{directory}/#{variant_id}.png"
|
||||
else
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
end
|
||||
end
|
||||
|
||||
# Gets object type for file paths and storage keys
|
||||
|
|
@ -87,6 +97,7 @@ module Granblue
|
|||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Directory name in game asset URL structure
|
||||
# @note Maps "main" -> "ls", "grid" -> "m", "square" -> "s"
|
||||
|
|
@ -95,6 +106,7 @@ module Granblue
|
|||
when 'main' then 'ls'
|
||||
when 'grid' then 'm'
|
||||
when 'square' then 's'
|
||||
when 'raw' then 'b'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'import_error'
|
||||
require 'csv'
|
||||
|
||||
module Granblue
|
||||
module Importers
|
||||
|
|
|
|||
|
|
@ -4,21 +4,26 @@ require 'pry'
|
|||
|
||||
module Granblue
|
||||
module Parsers
|
||||
|
||||
# CharacterParser parses character data from gbf.wiki
|
||||
class CharacterParser
|
||||
attr_reader :granblue_id
|
||||
|
||||
def initialize(granblue_id: String, debug: false)
|
||||
def initialize(granblue_id: String, debug: false, use_local: false)
|
||||
@character = Character.find_by(granblue_id: granblue_id)
|
||||
@wiki = GranblueWiki.new
|
||||
@wiki = Granblue::Parsers::Wiki.new
|
||||
@debug = debug || false
|
||||
@use_local = use_local
|
||||
end
|
||||
|
||||
# Fetches using @wiki and then processes the response
|
||||
# Returns true if successful, false if not
|
||||
# Raises an exception if something went wrong
|
||||
def fetch(save: false)
|
||||
if @use_local && @character.wiki_raw.present?
|
||||
wikitext = @character.wiki_raw
|
||||
return handle_fetch_success(wikitext, save)
|
||||
end
|
||||
|
||||
response = fetch_wiki_info
|
||||
return false if response.nil?
|
||||
|
||||
|
|
@ -49,6 +54,9 @@ module Granblue
|
|||
# Handle the response from the wiki if the response is successful
|
||||
# If the save flag is set, it will persist the data to the database
|
||||
def handle_fetch_success(response, save)
|
||||
@character.wiki_raw = response
|
||||
@character.save!
|
||||
|
||||
ap "#{@character.granblue_id}: Successfully fetched info for #{@character.wiki_en}" if @debug
|
||||
extracted = parse_string(response)
|
||||
info = parse(extracted)
|
||||
|
|
@ -152,12 +160,12 @@ module Granblue
|
|||
info[:id] = hash['id']
|
||||
info[:charid] = hash['charid'].scan(/\b\d{4}\b/)
|
||||
|
||||
info[:flb] = GranblueWiki.boolean.fetch(hash['5star'], false)
|
||||
info[:flb] = Granblue::Parsers::Wiki.boolean.fetch(hash['5star'], false)
|
||||
info[:ulb] = hash['max_evo'].to_i == 6
|
||||
|
||||
info[:rarity] = GranblueWiki.rarities.fetch(hash['rarity'], 0)
|
||||
info[:element] = GranblueWiki.elements.fetch(hash['element'], 0)
|
||||
info[:gender] = GranblueWiki.genders.fetch(hash['gender'], 0)
|
||||
info[:rarity] = Granblue::Parsers::Wiki.rarities.fetch(hash['rarity'], 0)
|
||||
info[:element] = Granblue::Parsers::Wiki.elements.fetch(hash['element'], 0)
|
||||
info[:gender] = Granblue::Parsers::Wiki.genders.fetch(hash['gender'], 0)
|
||||
|
||||
info[:proficiencies] = proficiencies_from_hash(hash['weapon'])
|
||||
info[:races] = races_from_hash(hash['race'])
|
||||
|
|
@ -211,14 +219,14 @@ module Granblue
|
|||
# Converts proficiencies from a string to a hash
|
||||
def proficiencies_from_hash(character)
|
||||
character.to_s.split(',').map.with_index do |prof, i|
|
||||
{ "proficiency#{i + 1}" => GranblueWiki.proficiencies[prof] }
|
||||
{ "proficiency#{i + 1}" => Granblue::Parsers::Wiki.proficiencies[prof] }
|
||||
end.reduce({}, :merge)
|
||||
end
|
||||
|
||||
# Converts races from a string to a hash
|
||||
def races_from_hash(race)
|
||||
race.to_s.split(',').map.with_index do |r, i|
|
||||
{ "race#{i + 1}" => GranblueWiki.races[r] }
|
||||
{ "race#{i + 1}" => Granblue::Parsers::Wiki.races[r] }
|
||||
end.reduce({}, :merge)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ module Granblue
|
|||
|
||||
def initialize(granblue_id: String, debug: false)
|
||||
@summon = Summon.find_by(granblue_id: granblue_id)
|
||||
@wiki = GranblueWiki.new(debug: debug)
|
||||
@wiki = Granblue::Parsers::Wiki.new
|
||||
@debug = debug || false
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ module Granblue
|
|||
|
||||
def initialize(granblue_id: String, debug: false)
|
||||
@weapon = Weapon.find_by(granblue_id: granblue_id)
|
||||
@wiki = GranblueWiki.new(debug: debug)
|
||||
@wiki = Granblue::Parsers::Wiki.new
|
||||
@debug = debug || false
|
||||
end
|
||||
|
||||
|
|
@ -278,17 +278,17 @@ module Granblue
|
|||
|
||||
# Converts rarities from a string to a hash
|
||||
def rarity_from_hash(string)
|
||||
string ? GranblueWiki.rarities[string.upcase] : nil
|
||||
string ? Granblue::Parsers::Wiki.rarities[string.upcase] : nil
|
||||
end
|
||||
|
||||
# Converts proficiencies from a string to a hash
|
||||
def proficiency_from_hash(string)
|
||||
GranblueWiki.proficiencies[string]
|
||||
Granblue::Parsers::Wiki.proficiencies[string]
|
||||
end
|
||||
|
||||
# Converts a bullet type from a string to a hash
|
||||
def bullet_from_hash(string)
|
||||
string ? GranblueWiki.bullets[string] : nil
|
||||
string ? Granblue::Parsers::Wiki.bullets[string] : nil
|
||||
end
|
||||
|
||||
# Parses a date string into a Date object
|
||||
|
|
|
|||
|
|
@ -40,54 +40,56 @@ module Granblue
|
|||
def transform
|
||||
Rails.logger.info "[TRANSFORM] Starting SummonTransformer#transform"
|
||||
|
||||
# Validate that input data is a Hash
|
||||
unless data.is_a?(Hash)
|
||||
Rails.logger.error "[TRANSFORM] Invalid summon data structure"
|
||||
Rails.logger.error "[TRANSFORM] Data class: #{data.class}"
|
||||
return []
|
||||
end
|
||||
|
||||
summons = []
|
||||
# Process each summon in the data
|
||||
data.each_value do |summon_data|
|
||||
# Determine the maximum index from the keys (assumed to be numeric strings).
|
||||
max_index = data.keys.map(&:to_i).max || 0
|
||||
# Pre-allocate an array so that key "1" ends up at index 0, etc.
|
||||
summons = Array.new(max_index)
|
||||
|
||||
# Process keys sorted numerically.
|
||||
data.keys.sort_by(&:to_i).each do |key|
|
||||
summon_data = data[key]
|
||||
Rails.logger.debug "[TRANSFORM] Processing summon: #{summon_data['master']['name'] if summon_data['master']}"
|
||||
|
||||
# Extract master and parameter data
|
||||
master, param = get_master_param(summon_data)
|
||||
unless master && param
|
||||
Rails.logger.debug "[TRANSFORM] Skipping summon - missing master or param data"
|
||||
next
|
||||
end
|
||||
|
||||
# Build base summon hash with required attributes
|
||||
# Build the base summon hash.
|
||||
summon = {
|
||||
name: master['name'], # Summon's display name
|
||||
id: master['id'], # Unique identifier
|
||||
uncap: param['evolution'].to_i # Current uncap level
|
||||
name: master['name'],
|
||||
id: master['id'],
|
||||
uncap: param['evolution'].to_i
|
||||
}
|
||||
|
||||
Rails.logger.debug "[TRANSFORM] Base summon data: #{summon}"
|
||||
# Calculate and add transcendence level.
|
||||
level = param['level'].to_i
|
||||
summon[:transcend] = calculate_transcendence_level(level)
|
||||
|
||||
# Add transcendence level for highly uncapped summons
|
||||
if summon[:uncap] > 5
|
||||
level = param['level'].to_i
|
||||
trans = calculate_transcendence_level(level)
|
||||
summon[:transcend] = trans
|
||||
Rails.logger.debug "[TRANSFORM] Added transcendence level: #{trans}"
|
||||
end
|
||||
|
||||
# Mark quick summon status if this summon matches quick_summon_id
|
||||
# Mark quick summon status if this summon matches quick_summon_id.
|
||||
if @quick_summon_id && param['id'].to_s == @quick_summon_id.to_s
|
||||
summon[:qs] = true
|
||||
Rails.logger.debug "[TRANSFORM] Marked as quick summon"
|
||||
end
|
||||
|
||||
summons << summon
|
||||
# Include subaura (sub_skill) information if present.
|
||||
if summon_data['sub_skill'].is_a?(Hash) && summon_data['sub_skill']['name']
|
||||
summon[:sub_aura] = summon_data['sub_skill']['name']
|
||||
end
|
||||
|
||||
# Place the summon in the proper 0-indexed slot.
|
||||
summons[key.to_i - 1] = summon
|
||||
Rails.logger.info "[TRANSFORM] Successfully processed summon #{summon[:name]}"
|
||||
end
|
||||
|
||||
Rails.logger.info "[TRANSFORM] Completed processing #{summons.length} summons"
|
||||
summons
|
||||
Rails.logger.info "[TRANSFORM] Completed processing #{summons.compact.length} summons"
|
||||
summons.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -6,6 +6,25 @@ module PostDeployment
|
|||
class DatabaseMigrator
|
||||
include LoggingHelper
|
||||
|
||||
class CombinedMigration
|
||||
attr_reader :version, :name, :migration, :type
|
||||
|
||||
def initialize(version, name, migration, type)
|
||||
@version = version
|
||||
@name = name
|
||||
@migration = migration
|
||||
@type = type
|
||||
end
|
||||
|
||||
def schema_migration?
|
||||
@type == :schema
|
||||
end
|
||||
|
||||
def data_migration?
|
||||
@type == :data
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(test_mode:, verbose:)
|
||||
@test_mode = test_mode
|
||||
@verbose = verbose
|
||||
|
|
@ -24,64 +43,80 @@ module PostDeployment
|
|||
|
||||
private
|
||||
|
||||
def simulate_migrations
|
||||
log_step "TEST MODE: Would run pending migrations..."
|
||||
def collect_pending_migrations
|
||||
# Collect schema migrations
|
||||
schema_context = ActiveRecord::Base.connection.pool.migration_context
|
||||
schema_migrations = schema_context.migrations.map do |migration|
|
||||
CombinedMigration.new(
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration,
|
||||
:schema
|
||||
)
|
||||
end
|
||||
|
||||
# Check schema migrations
|
||||
pending_schema_migrations = ActiveRecord::Base.connection.pool.migration_context.needs_migration?
|
||||
schema_migrations = ActiveRecord::Base.connection.pool.migration_context.migrations
|
||||
|
||||
# Check data migrations
|
||||
# Collect data migrations
|
||||
data_migrations_path = DataMigrate.config.data_migrations_path
|
||||
data_migration_context = DataMigrate::MigrationContext.new(data_migrations_path)
|
||||
pending_data_migrations = data_migration_context.needs_migration?
|
||||
data_migrations = data_migration_context.migrations
|
||||
data_migrations = data_migration_context.migrations.map do |migration|
|
||||
CombinedMigration.new(
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration,
|
||||
:data
|
||||
)
|
||||
end
|
||||
|
||||
if pending_schema_migrations || pending_data_migrations
|
||||
if schema_migrations.any?
|
||||
log_step "Would apply #{schema_migrations.size} pending schema migrations:"
|
||||
schema_migrations.each do |migration|
|
||||
log_step " • #{migration.name}"
|
||||
end
|
||||
end
|
||||
# Combine and sort all migrations by version
|
||||
(schema_migrations + data_migrations).sort_by(&:version)
|
||||
end
|
||||
|
||||
if data_migrations.any?
|
||||
log_step "\nWould apply #{data_migrations.size} pending data migrations:"
|
||||
data_migrations.each do |migration|
|
||||
log_step " • #{migration.name}"
|
||||
end
|
||||
def simulate_migrations
|
||||
pending_migrations = collect_pending_migrations
|
||||
|
||||
if pending_migrations.any?
|
||||
log_step "TEST MODE: Would run #{pending_migrations.size} pending migrations in this order:"
|
||||
pending_migrations.each do |migration|
|
||||
type = migration.schema_migration? ? 'schema' : 'data'
|
||||
log_step " • [#{type}] #{migration.name} (#{migration.version})"
|
||||
end
|
||||
else
|
||||
log_step "No pending migrations."
|
||||
log_step 'No pending migrations.'
|
||||
end
|
||||
end
|
||||
|
||||
def perform_migrations
|
||||
ActiveRecord::Migration.verbose = @verbose
|
||||
pending_migrations = collect_pending_migrations
|
||||
|
||||
# Run schema migrations
|
||||
schema_version = ActiveRecord::Base.connection.pool.migration_context.current_version
|
||||
ActiveRecord::Tasks::DatabaseTasks.migrate
|
||||
new_schema_version = ActiveRecord::Base.connection.pool.migration_context.current_version
|
||||
return log_step 'No pending migrations.' if pending_migrations.empty?
|
||||
|
||||
# Run data migrations
|
||||
data_migrations_path = DataMigrate.config.data_migrations_path
|
||||
data_migration_context = DataMigrate::MigrationContext.new(data_migrations_path)
|
||||
schema_context = ActiveRecord::Base.connection.pool.migration_context
|
||||
data_context = DataMigrate::MigrationContext.new(DataMigrate.config.data_migrations_path)
|
||||
|
||||
data_version = data_migration_context.current_version
|
||||
data_migration_context.migrate
|
||||
new_data_version = data_migration_context.current_version
|
||||
initial_schema_version = schema_context.current_version
|
||||
initial_data_version = data_context.current_version
|
||||
|
||||
if schema_version == new_schema_version && data_version == new_data_version
|
||||
log_step "No pending migrations."
|
||||
else
|
||||
if schema_version != new_schema_version
|
||||
log_step "Migrated schema from version #{schema_version} to #{new_schema_version}"
|
||||
end
|
||||
if data_version != new_data_version
|
||||
log_step "Migrated data from version #{data_version} to #{new_data_version}"
|
||||
pending_migrations.each do |combined_migration|
|
||||
if combined_migration.schema_migration?
|
||||
# Execute schema migration using Rails migration context
|
||||
schema_context.run(:up, combined_migration.version)
|
||||
else
|
||||
# Execute data migration using data-migrate context
|
||||
data_context.run(:up, combined_migration.version)
|
||||
end
|
||||
end
|
||||
|
||||
final_schema_version = schema_context.current_version
|
||||
final_data_version = data_context.current_version
|
||||
|
||||
if initial_schema_version != final_schema_version
|
||||
log_step "Migrated schema from version #{initial_schema_version} to #{final_schema_version}"
|
||||
end
|
||||
|
||||
if initial_data_version != final_data_version
|
||||
log_step "Migrated data from version #{initial_data_version} to #{final_data_version}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
42
lib/tasks/database.rake
Normal file
42
lib/tasks/database.rake
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
namespace :db do
|
||||
desc 'Backup remote PostgreSQL database'
|
||||
task :backup do
|
||||
remote_host = ENV.fetch('REMOTE_DB_HOST', 'roundhouse.proxy.rlwy.net')
|
||||
remote_port = ENV.fetch('REMOTE_DB_PORT', '54629')
|
||||
remote_user = ENV.fetch('REMOTE_DB_USER', 'postgres')
|
||||
remote_db = ENV.fetch('REMOTE_DB_NAME', 'railway')
|
||||
password = ENV.fetch('REMOTE_DB_PASSWORD') { raise 'Please set REMOTE_DB_PASSWORD' }
|
||||
|
||||
backup_dir = File.expand_path('backups')
|
||||
FileUtils.mkdir_p(backup_dir)
|
||||
backup_file = File.join(backup_dir, "#{Time.now.strftime('%Y%m%d_%H%M%S')}-prod-backup.tar")
|
||||
|
||||
cmd = %W[
|
||||
pg_dump -h #{remote_host} -p #{remote_port} -U #{remote_user} -d #{remote_db} -F t
|
||||
--no-owner --exclude-extension=timescaledb --exclude-extension=timescaledb_toolkit
|
||||
].join(' ')
|
||||
|
||||
puts "Backing up remote database to #{backup_file}..."
|
||||
system({ 'PGPASSWORD' => password }, "#{cmd} > #{backup_file}")
|
||||
puts 'Backup completed!'
|
||||
end
|
||||
|
||||
desc 'Restore PostgreSQL database from backup'
|
||||
task :restore, [:backup_file] => [:environment] do |_, args|
|
||||
local_user = ENV.fetch('LOCAL_DB_USER', 'justin')
|
||||
local_db = ENV.fetch('LOCAL_DB_NAME', 'hensei_dev')
|
||||
|
||||
# Use the specified backup file or find the most recent one
|
||||
backup_dir = File.expand_path('backups')
|
||||
backup_file = args[:backup_file] || Dir.glob("#{backup_dir}/*-prod-backup.tar").max
|
||||
|
||||
raise 'Backup file not found. Please specify a valid backup file.' unless backup_file && File.exist?(backup_file)
|
||||
|
||||
puts "Restoring database from #{backup_file}..."
|
||||
system("pg_restore --no-owner --role=#{local_user} --disable-triggers -U #{local_user} -d #{local_db} #{backup_file}")
|
||||
puts 'Restore completed!'
|
||||
end
|
||||
|
||||
desc 'Backup remote database and restore locally'
|
||||
task backup_and_restore: %i[backup restore]
|
||||
end
|
||||
|
|
@ -1,40 +1,50 @@
|
|||
namespace :granblue do
|
||||
def _progress_reporter(count:, total:, result:, bar_len: 40, multi: true)
|
||||
filled_len = (bar_len * count / total).round
|
||||
status = File.basename(result)
|
||||
percents = (100.0 * count / total).round(1)
|
||||
bar = '=' * filled_len + '-' * (bar_len - filled_len)
|
||||
desc 'Downloads all images for the given object type'
|
||||
# Downloads all images for a specific type of game object (e.g. summons, weapons)
|
||||
# Uses the appropriate downloader class based on the object type
|
||||
#
|
||||
# @param object [String] Type of object to download images for (e.g. 'summon', 'weapon')
|
||||
# @example Download all summon images
|
||||
# rake granblue:download_all_images\[summon\]
|
||||
# @example Download all weapon images
|
||||
# rake granblue:download_all_images\[weapon\]
|
||||
# @example Download all character images
|
||||
# rake granblue:download_all_images\[character\]
|
||||
task :download_all_images, %i[object threads size] => :environment do |_t, args|
|
||||
require 'parallel'
|
||||
require 'logger'
|
||||
|
||||
if !multi
|
||||
print("[#{bar}] #{percents}% ...#{' ' * 14}#{status}\n")
|
||||
else
|
||||
print "\n"
|
||||
end
|
||||
end
|
||||
# Use a thread-safe logger (or Rails.logger if preferred)
|
||||
logger = Logger.new($stdout)
|
||||
logger.level = Logger::INFO # set to WARN or INFO to reduce debug noise
|
||||
|
||||
desc 'Downloads images for the given object type at the given size'
|
||||
task :download_all_images, %i[object size] => :environment do |_t, args|
|
||||
require 'open-uri'
|
||||
# Load downloader classes
|
||||
require_relative '../granblue/downloaders/base_downloader'
|
||||
Dir[Rails.root.join('lib', 'granblue', 'downloaders', '*.rb')].each { |file| require file }
|
||||
|
||||
filename = "export/#{args[:object]}-#{args[:size]}.txt"
|
||||
count = `wc -l #{filename}`.split.first.to_i
|
||||
object = args[:object]
|
||||
specified_size = args[:size]
|
||||
klass = object.classify.constantize
|
||||
ids = klass.pluck(:granblue_id)
|
||||
|
||||
path = "#{Rails.root}/download/#{args[:object]}-#{args[:size]}"
|
||||
FileUtils.mkdir_p(path) unless Dir.exist?(path)
|
||||
puts "Downloading images for #{ids.count} #{object.pluralize}..."
|
||||
|
||||
puts "Downloading #{count} images from #{args[:object]}-#{args[:size]}.txt..."
|
||||
if File.exist?(filename)
|
||||
File.readlines(filename).each_with_index do |line, i|
|
||||
download = URI.parse(line.strip).open
|
||||
download_URI = "#{path}/#{download.base_uri.to_s.split('/')[-1]}"
|
||||
if File.exist?(download_URI)
|
||||
puts "Skipping #{line}"
|
||||
logger.info "Downloading images for #{ids.count} #{object.pluralize}..."
|
||||
thread_count = (args[:threads] || 4).to_i
|
||||
logger.info "Using #{thread_count} threads for parallel downloads..."
|
||||
logger.info "Downloading only size: #{specified_size}" if specified_size
|
||||
|
||||
Parallel.each(ids, in_threads: thread_count) do |id|
|
||||
ActiveRecord::Base.connection_pool.with_connection do
|
||||
downloader_class = "Granblue::Downloaders::#{object.classify}Downloader".constantize
|
||||
downloader = downloader_class.new(id, verbose: true, logger: logger)
|
||||
if specified_size
|
||||
downloader.download(specified_size)
|
||||
else
|
||||
IO.copy_stream(download, "#{path}/#{download.base_uri.to_s.split('/')[-1]}")
|
||||
_progress_reporter(count: i, total: count, result: download_URI, bar_len: 40, multi: false)
|
||||
downloader.download
|
||||
end
|
||||
rescue StandardError => e
|
||||
puts "#{e}: #{line}"
|
||||
logger.error "Error downloading #{object} #{id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
79
lib/tasks/fetch_wiki.rake
Normal file
79
lib/tasks/fetch_wiki.rake
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
namespace :granblue do
|
||||
desc <<~DESC
|
||||
Fetch and store raw wiki data for objects (Character, Weapon, Summon).
|
||||
|
||||
Usage:
|
||||
rake granblue:fetch_wiki_data # Fetch all Characters (default)
|
||||
rake granblue:fetch_wiki_data type=Weapon # Fetch all Weapons
|
||||
rake granblue:fetch_wiki_data type=Summon # Fetch all Summons
|
||||
rake granblue:fetch_wiki_data type=Character id=5 # Fetch specific Character by ID
|
||||
rake granblue:fetch_wiki_data force=true # Force re-download even if data exists
|
||||
DESC
|
||||
task fetch_wiki_data: :environment do
|
||||
# Get parameters from environment
|
||||
type = (ENV['type'] || 'Character').classify
|
||||
id = ENV['id']
|
||||
force = ENV['force'] == 'true'
|
||||
|
||||
# Validate object type
|
||||
valid_types = %w[Character Weapon Summon]
|
||||
unless valid_types.include?(type)
|
||||
puts "Error: Invalid type '#{type}'. Must be one of: #{valid_types.join(', ')}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Get the class from the type string
|
||||
klass = type.constantize
|
||||
|
||||
# Setup query - either all objects or specific one
|
||||
query = id.present? ? klass.where(granblue_id: id) : klass.all
|
||||
|
||||
errors = []
|
||||
count = 0
|
||||
|
||||
query.find_each do |object|
|
||||
# Skip objects that already have wiki_raw if force is not set
|
||||
if object.wiki_raw.present? && !force
|
||||
puts "Skipping #{object.name_en} (already has wiki_raw)."
|
||||
next
|
||||
end
|
||||
|
||||
# If the object doesn't have a wiki page specified, skip
|
||||
if object.wiki_en.blank?
|
||||
puts "Skipping #{object.name_en} (no wiki_en set)."
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
# 1) Fetch raw wikitext from the wiki
|
||||
wiki_text = Granblue::Parsers::Wiki.new.fetch(object.wiki_en)
|
||||
|
||||
# 2) Check if the page is a redirect
|
||||
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
|
||||
if redirect_match
|
||||
redirect_target = redirect_match[1]
|
||||
# Update object to new wiki_en so we don't keep fetching the old page
|
||||
object.update!(wiki_en: redirect_target)
|
||||
# Fetch again with the new page name
|
||||
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
|
||||
end
|
||||
puts wiki_text
|
||||
|
||||
# 3) Save raw wiki text in the object record
|
||||
object.update!(wiki_raw: wiki_text)
|
||||
puts "Saved wiki data for #{object.name_en} (#{object.id})"
|
||||
count += 1
|
||||
rescue StandardError => e
|
||||
errors << { object_id: object.id, type: type, error: e.message }
|
||||
puts "Error fetching data for #{object.name_en}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if errors.any?
|
||||
puts "#{errors.size} #{type.pluralize} had errors:"
|
||||
errors.each { |err| puts " - #{err[:type]} ##{err[:object_id]} => #{err[:error]}" }
|
||||
else
|
||||
puts "Wiki data fetch complete for #{count} #{type.pluralize} with no errors!"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,7 +7,8 @@ namespace :granblue do
|
|||
Dir[Rails.root.join('lib', 'granblue', '**', '*.rb')].each { |file| require file }
|
||||
|
||||
test_mode = ENV['TEST'] == 'true'
|
||||
importer = Granblue::PostDeployment::DataImporter.new(test_mode: test_mode)
|
||||
verbose = ENV['VERBOSE'] == 'true'
|
||||
importer = PostDeployment::DataImporter.new(test_mode: test_mode, verbose: verbose)
|
||||
importer.process_all_files
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
module Api
|
||||
module V1
|
||||
class PartiesController < Api::V1::ApiController
|
||||
@parties: ActiveRecord::Relation[Party]
|
||||
|
||||
MAX_CHARACTERS: Integer
|
||||
MAX_SUMMONS: Integer
|
||||
MAX_WEAPONS: Integer
|
||||
DEFAULT_MIN_CHARACTERS: Integer
|
||||
DEFAULT_MIN_SUMMONS: Integer
|
||||
DEFAULT_MIN_WEAPONS: Integer
|
||||
DEFAULT_MAX_CLEAR_TIME: Integer
|
||||
|
||||
def create: () -> void
|
||||
|
||||
def show: () -> void
|
||||
|
||||
def update: () -> void
|
||||
|
||||
def destroy: () -> void
|
||||
|
||||
def remix: () -> void
|
||||
|
||||
def index: () -> void
|
||||
|
||||
def favorites: () -> void
|
||||
|
||||
def preview: () -> void
|
||||
|
||||
def preview_status: () -> void
|
||||
|
||||
def regenerate_preview: () -> void
|
||||
|
||||
private
|
||||
|
||||
def authorize: () -> void
|
||||
|
||||
def grid_table_and_object_table: (String) -> [String?, String?]
|
||||
|
||||
def not_owner: () -> bool
|
||||
|
||||
def schedule_preview_generation: () -> void
|
||||
|
||||
def apply_filters: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def apply_privacy_settings: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def apply_includes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def apply_excludes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def build_filters: () -> Hash[Symbol, untyped]
|
||||
|
||||
def build_date_range: () -> Range[DateTime]?
|
||||
|
||||
def build_start_time: (String?) -> DateTime?
|
||||
|
||||
def build_count: (String?, Integer) -> Integer
|
||||
|
||||
def build_max_clear_time: (String?) -> Integer
|
||||
|
||||
def build_element: (String?) -> Integer?
|
||||
|
||||
def build_option: (String?) -> Integer?
|
||||
|
||||
def build_query: (Hash[Symbol, untyped], bool) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def fetch_parties: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def calculate_count: (ActiveRecord::Relation[Party]) -> Integer
|
||||
|
||||
def calculate_total_pages: (Integer) -> Integer
|
||||
|
||||
def paginate_parties: (
|
||||
ActiveRecord::Relation[Party],
|
||||
?page: Integer?,
|
||||
?per_page: Integer
|
||||
) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def excluded_characters: () -> ActiveRecord::Relation[GridCharacter]?
|
||||
|
||||
def excluded_summons: () -> ActiveRecord::Relation[GridSummon]?
|
||||
|
||||
def excluded_weapons: () -> ActiveRecord::Relation[GridWeapon]?
|
||||
|
||||
def render_party_json: (ActiveRecord::Relation[Party]) -> void
|
||||
|
||||
def privacy: (?favorites: bool) -> String?
|
||||
|
||||
def user_quality: () -> String?
|
||||
|
||||
def name_quality: () -> String?
|
||||
|
||||
def original: () -> String?
|
||||
|
||||
def includes: (String) -> String
|
||||
|
||||
def excludes: (String) -> String
|
||||
|
||||
def id_to_table: (String) -> String
|
||||
|
||||
def remixed_name: (String) -> String
|
||||
|
||||
def set_from_slug: () -> void
|
||||
|
||||
def set: () -> void
|
||||
|
||||
def party_params: () -> Hash[Symbol, untyped]?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
class AwsService
|
||||
class ConfigurationError < StandardError
|
||||
end
|
||||
|
||||
attr_reader bucket: String
|
||||
attr_reader s3_client: Aws::S3::Client
|
||||
|
||||
def initialize: () -> void
|
||||
|
||||
def upload_stream: (IO io, String key) -> Aws::S3::Types::PutObjectOutput
|
||||
|
||||
def file_exists?: (String key) -> bool
|
||||
|
||||
private
|
||||
|
||||
def credentials: () -> Hash[Symbol, String]
|
||||
|
||||
def validate_credentials!: () -> void
|
||||
end
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
module Granblue
|
||||
module Downloaders
|
||||
class BaseDownloader
|
||||
SIZES: Array[String]
|
||||
|
||||
# Define allowed storage types
|
||||
type storage = :local | :s3 | :both
|
||||
|
||||
@id: String
|
||||
@base_url: String
|
||||
@test_mode: bool
|
||||
@verbose: bool
|
||||
@storage: storage
|
||||
@aws_service: AwsService
|
||||
|
||||
def initialize: (String id, ?test_mode: bool, ?verbose: bool, ?storage: storage) -> void
|
||||
|
||||
def download: -> void
|
||||
|
||||
private
|
||||
|
||||
def process_download: (String url, String size, String path, ?last: bool) -> void
|
||||
|
||||
def download_to_local: (String url, String download_uri) -> void
|
||||
|
||||
def stream_to_s3: (String url, String s3_key) -> void
|
||||
|
||||
def download_to_both: (String url, String download_uri, String s3_key) -> void
|
||||
|
||||
def should_download?: (String local_path, String s3_key) -> bool
|
||||
|
||||
def ensure_directories_exist: -> void
|
||||
|
||||
def store_locally?: -> bool
|
||||
|
||||
def download_path: (String size) -> String
|
||||
|
||||
def build_s3_key: (String size, String filename) -> String
|
||||
|
||||
def log_info: (String message) -> void
|
||||
|
||||
def download_elemental_image: (String url, String size, String path, String filename) -> void
|
||||
|
||||
def object_type: -> String
|
||||
|
||||
def base_url: -> String
|
||||
|
||||
def directory_for_size: (String size) -> String
|
||||
|
||||
def build_url: (String size) -> String
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
module Granblue
|
||||
module Downloaders
|
||||
class CharacterDownloader < BaseDownloader
|
||||
private
|
||||
|
||||
def download_variants: (Character character) -> void
|
||||
|
||||
def download_variant: (String variant_id) -> void
|
||||
|
||||
def build_variant_url: (String variant_id, String size) -> String
|
||||
|
||||
def object_type: -> String
|
||||
|
||||
def base_url: -> String
|
||||
|
||||
def directory_for_size: (String size) -> String
|
||||
|
||||
private
|
||||
|
||||
@id: String
|
||||
@base_url: String
|
||||
@test_mode: bool
|
||||
@verbose: bool
|
||||
@storage: Symbol
|
||||
@aws_service: AwsService
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue