Compare commits
7 commits
main
...
jedmund/em
| Author | SHA1 | Date | |
|---|---|---|---|
| 00890eda10 | |||
| dc55e7cdee | |||
| 0257469582 | |||
| 8d047be149 | |||
| a55ea1e50f | |||
| 74b59efd5f | |||
| c0df0fbd13 |
183 changed files with 2377 additions and 15148 deletions
|
|
@ -1,26 +0,0 @@
|
|||
.*
|
||||
|
||||
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/*
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -7,9 +7,6 @@
|
|||
# Ignore bundler config.
|
||||
/.bundle
|
||||
|
||||
# Ignore mystery Postgres folder
|
||||
/postgres
|
||||
|
||||
# Ignore the default SQLite database.
|
||||
/db/*.sqlite3
|
||||
/db/*.sqlite3-journal
|
||||
|
|
@ -21,9 +18,6 @@
|
|||
!/log/.keep
|
||||
!/tmp/.keep
|
||||
|
||||
# Ignore simplecov directory
|
||||
/coverage/*
|
||||
|
||||
# Ignore pidfiles, but keep the directory.
|
||||
/tmp/pids/*
|
||||
!/tmp/pids/
|
||||
|
|
@ -37,12 +31,9 @@
|
|||
# Ignore master key for decrypting credentials and more.
|
||||
/config/master.key
|
||||
|
||||
# Ignore specific directories
|
||||
/.local
|
||||
# Ignore exported and downloaded files
|
||||
/export
|
||||
/download
|
||||
/backups
|
||||
/logs
|
||||
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -55,7 +46,3 @@ config/application.yml
|
|||
.vscode/*
|
||||
|
||||
/config/credentials/production.key
|
||||
|
||||
# Ignore AI Codebase-generated files
|
||||
codebase.md
|
||||
mise.toml
|
||||
|
|
|
|||
1
.rspec
1
.rspec
|
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
Layout/MultilineOperationIndentation:
|
||||
EnforcedStyle: aligned
|
||||
28
Gemfile
28
Gemfile
|
|
@ -2,6 +2,7 @@ source 'https://rubygems.org'
|
|||
ruby '3.3.7'
|
||||
|
||||
gem 'bootsnap'
|
||||
gem 'pg'
|
||||
gem 'rack-cors'
|
||||
gem 'rails'
|
||||
gem 'sprockets-rails'
|
||||
|
|
@ -9,9 +10,6 @@ gem 'sprockets-rails'
|
|||
# A Ruby Web Server Built For Concurrency
|
||||
gem 'puma'
|
||||
|
||||
# Pg is the Ruby interface to the PostgreSQL RDBMS
|
||||
gem 'pg'
|
||||
|
||||
# A sophisticated and secure hash algorithm for
|
||||
# hashing passwords.
|
||||
gem 'bcrypt'
|
||||
|
|
@ -49,12 +47,6 @@ gem 'email_validator'
|
|||
# pg_search builds ActiveRecord named scopes that take advantage of PostgreSQL’s full text search
|
||||
gem 'pg_search'
|
||||
|
||||
# A Ruby client library for Redis
|
||||
gem 'redis'
|
||||
|
||||
# Simple, efficient background processing for Ruby
|
||||
gem 'sidekiq'
|
||||
|
||||
# scheduler for Ruby (at, in, cron and every jobs)
|
||||
gem 'rufus-scheduler'
|
||||
|
||||
|
|
@ -73,17 +65,6 @@ gem 'httparty'
|
|||
# StringScanner provides for lexical scanning operations on a String.
|
||||
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'
|
||||
|
|
@ -92,7 +73,8 @@ end
|
|||
group :development, :test do
|
||||
gem 'amazing_print'
|
||||
gem 'dotenv-rails'
|
||||
gem 'prosopite'
|
||||
gem 'factory_bot_rails'
|
||||
gem 'faker'
|
||||
gem 'pry'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails'
|
||||
|
|
@ -100,7 +82,6 @@ end
|
|||
|
||||
group :development do
|
||||
gem 'listen'
|
||||
gem 'pg_query'
|
||||
gem 'solargraph'
|
||||
gem 'spring'
|
||||
gem 'spring-commands-rspec'
|
||||
|
|
@ -115,9 +96,6 @@ 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
|
||||
|
|
|
|||
117
Gemfile.lock
117
Gemfile.lock
|
|
@ -86,8 +86,8 @@ GEM
|
|||
awesome_nested_set (3.8.0)
|
||||
activerecord (>= 4.0.0, < 8.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1044.0)
|
||||
aws-sdk-core (3.217.1)
|
||||
aws-partitions (1.1038.0)
|
||||
aws-sdk-core (3.216.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
|
@ -95,7 +95,7 @@ GEM
|
|||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.179.0)
|
||||
aws-sdk-s3 (1.178.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
|
|
@ -135,13 +135,14 @@ GEM
|
|||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
e2mmap (0.1.0)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.1)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot (6.5.0)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.4.4)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 5.0.0)
|
||||
|
|
@ -165,30 +166,14 @@ GEM
|
|||
gemoji (>= 2.1.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.29.3)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-aarch64-linux)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-arm64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-x86_64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-x86_64-linux)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
irb (1.14.3)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
|
|
@ -198,7 +183,7 @@ GEM
|
|||
rexml (>= 3.3.9)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.4)
|
||||
language_server-protocol (3.17.0.3)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
|
|
@ -228,54 +213,46 @@ GEM
|
|||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
newrelic_rpm (9.17.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
nokogiri (1.18.1-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
nokogiri (1.18.1-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
nokogiri (1.18.1-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
nokogiri (1.18.1-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
observer (0.1.2)
|
||||
oj (3.16.9)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
parser (3.3.6.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pg_query (6.0.0)
|
||||
google-protobuf (>= 3.25.3)
|
||||
pg_search (2.3.7)
|
||||
activerecord (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prosopite (1.4.2)
|
||||
pry (0.15.2)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
psych (5.2.3)
|
||||
psych (5.2.2)
|
||||
date
|
||||
stringio
|
||||
puma (6.6.0)
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.9)
|
||||
rack (3.1.8)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-session (2.1.0)
|
||||
|
|
@ -319,21 +296,16 @@ GEM
|
|||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.11.0)
|
||||
rbs (2.8.4)
|
||||
rdoc (6.10.0)
|
||||
psych (>= 4.0.0)
|
||||
redis (5.3.0)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.23.2)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (3.0.0)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.0)
|
||||
rspec (3.13.0)
|
||||
|
|
@ -359,17 +331,17 @@ GEM
|
|||
rspec-support (3.13.2)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.71.1)
|
||||
rubocop (1.70.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rufus-scheduler (3.9.2)
|
||||
|
|
@ -377,40 +349,26 @@ 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)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solargraph (0.51.1)
|
||||
solargraph (0.50.0)
|
||||
backport (~> 1.2)
|
||||
benchmark
|
||||
bundler (~> 2.0)
|
||||
diff-lcs (~> 1.4)
|
||||
jaro_winkler (~> 1.6)
|
||||
e2mmap
|
||||
jaro_winkler (~> 1.5)
|
||||
kramdown (~> 2.3)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
logger (~> 1.6)
|
||||
observer (~> 0.1)
|
||||
ostruct (~> 0.6)
|
||||
parser (~> 3.0)
|
||||
rbs (~> 3.0)
|
||||
reverse_markdown (>= 2.0, < 4)
|
||||
rbs (~> 2.0)
|
||||
reverse_markdown (~> 2.0)
|
||||
rubocop (~> 1.38)
|
||||
thor (~> 1.0)
|
||||
tilt (~> 2.0)
|
||||
|
|
@ -426,7 +384,6 @@ 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)
|
||||
|
|
@ -480,36 +437,26 @@ DEPENDENCIES
|
|||
httparty
|
||||
listen
|
||||
mini_magick
|
||||
newrelic_rpm
|
||||
oj
|
||||
parallel
|
||||
pg
|
||||
pg_query
|
||||
pg_search
|
||||
prosopite
|
||||
pry
|
||||
puma
|
||||
rack-cors
|
||||
rails
|
||||
redis
|
||||
responders
|
||||
rspec
|
||||
rspec-rails
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rufus-scheduler
|
||||
sdoc
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
shoulda-matchers
|
||||
sidekiq
|
||||
simplecov
|
||||
solargraph
|
||||
spring
|
||||
spring-commands-rspec
|
||||
sprockets-rails
|
||||
squasher (>= 0.6.0)
|
||||
stackprof
|
||||
strscan
|
||||
will_paginate (~> 3.3)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,77 +3,72 @@
|
|||
module Api
|
||||
module V1
|
||||
class CharacterBlueprint < ApiBlueprint
|
||||
field :name do |c|
|
||||
field :name do |w|
|
||||
{
|
||||
en: c.name_en,
|
||||
ja: c.name_jp
|
||||
en: w.name_en,
|
||||
ja: w.name_jp
|
||||
}
|
||||
end
|
||||
|
||||
fields :granblue_id, :character_id, :rarity,
|
||||
:element, :gender, :special
|
||||
|
||||
field :uncap do |c|
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: c.flb,
|
||||
ulb: c.ulb
|
||||
flb: w.flb,
|
||||
ulb: w.ulb
|
||||
}
|
||||
end
|
||||
|
||||
field :race do |c|
|
||||
[c.race1, c.race2].compact
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :proficiency do |c|
|
||||
[c.proficiency1, c.proficiency2].compact
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :rates
|
||||
include_view :dates
|
||||
field :race do |w|
|
||||
[
|
||||
w.race1,
|
||||
w.race2
|
||||
]
|
||||
end
|
||||
|
||||
field :awakenings do
|
||||
Character::AWAKENINGS.map do |awakening|
|
||||
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
|
||||
end
|
||||
field :proficiency do |w|
|
||||
[
|
||||
w.proficiency1,
|
||||
w.proficiency2
|
||||
]
|
||||
end
|
||||
|
||||
field :data do |w|
|
||||
{
|
||||
base_da: w.base_da,
|
||||
base_ta: w.base_ta
|
||||
}
|
||||
end
|
||||
|
||||
field :ougi_ratio do |w|
|
||||
{
|
||||
ougi_ratio: w.ougi_ratio,
|
||||
ougi_ratio_flb: w.ougi_ratio_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :awakenings do
|
||||
Awakening.where(object_type: 'Character').map do |a|
|
||||
AwakeningBlueprint.render_as_hash(a)
|
||||
end
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |c|
|
||||
{
|
||||
min_hp: c.min_hp,
|
||||
max_hp: c.max_hp,
|
||||
max_hp_flb: c.max_hp_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |c|
|
||||
{
|
||||
min_atk: c.min_atk,
|
||||
max_atk: c.max_atk,
|
||||
max_atk_flb: c.max_atk_flb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :rates do
|
||||
fields :base_da, :base_ta
|
||||
|
||||
field :ougi_ratio do |c|
|
||||
{
|
||||
ougi_ratio: c.ougi_ratio,
|
||||
ougi_ratio_flb: c.ougi_ratio_flb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,64 +3,57 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridCharacterBlueprint < ApiBlueprint
|
||||
fields :position, :uncap_level, :perpetuity
|
||||
|
||||
field :transcendence_step, if: ->(_field, gc, _options) { gc.character&.ulb } do |gc|
|
||||
gc.transcendence_step
|
||||
end
|
||||
|
||||
view :preview do
|
||||
association :character, name: :object, blueprint: CharacterBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :nested do
|
||||
include_view :mastery_bonuses
|
||||
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
|
||||
fields :position, :uncap_level, :perpetuity
|
||||
|
||||
field :transcendence_step, if: lambda { |_fn, obj, _opt|
|
||||
obj.character.ulb
|
||||
} do |c|
|
||||
c.transcendence_step
|
||||
end
|
||||
|
||||
field :awakening do |c|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(c.awakening),
|
||||
level: c.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
field :over_mastery, if: lambda { |_fn, obj, _opt|
|
||||
!obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
|
||||
} do |c|
|
||||
rings = []
|
||||
|
||||
rings.push(c.ring1) unless c.ring1['modifier'].nil?
|
||||
rings.push(c.ring2) unless c.ring2['modifier'].nil?
|
||||
rings.push(c.ring3) unless c.ring3['modifier'].nil?
|
||||
rings.push(c.ring4) unless c.ring4['modifier'].nil?
|
||||
|
||||
rings
|
||||
end
|
||||
|
||||
field :aetherial_mastery, if: lambda { |_fn, obj, _opt|
|
||||
!obj.earring['modifier'].nil?
|
||||
} do |c|
|
||||
c.earring
|
||||
end
|
||||
|
||||
association :character, name: :object, blueprint: CharacterBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
fields :position, :created_at, :updated_at
|
||||
end
|
||||
|
||||
view :mastery_bonuses do
|
||||
field :awakening, if: ->(_field_name, gc, _options) { gc.association(:awakening).loaded? } do |gc|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(gc.awakening),
|
||||
level: gc.awakening_level.to_i
|
||||
}
|
||||
end
|
||||
|
||||
field :over_mastery, if: lambda { |_fn, obj, _opt|
|
||||
obj.ring1.present? && obj.ring2.present? && !obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
|
||||
} do |c|
|
||||
mapped_rings = [c.ring1, c.ring2, c.ring3, c.ring4].each_with_object([]) do |ring, arr|
|
||||
# Skip if the ring is nil or its modifier is blank.
|
||||
next if ring.blank? || ring['modifier'].blank?
|
||||
|
||||
# Convert the string values to numbers.
|
||||
mod = ring['modifier'].to_i
|
||||
|
||||
# Only include if modifier is non-zero.
|
||||
next if mod.zero?
|
||||
|
||||
arr << { modifier: mod, strength: ring['strength'].to_i }
|
||||
end
|
||||
|
||||
mapped_rings
|
||||
end
|
||||
|
||||
field :aetherial_mastery, if: ->(_fn, obj, _opt) { obj.earring.present? && !obj.earring['modifier'].nil? } do |gc, _options|
|
||||
{
|
||||
modifier: gc.earring['modifier'].to_i,
|
||||
strength: gc.earring['strength'].to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,24 +3,19 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridSummonBlueprint < ApiBlueprint
|
||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
|
||||
view :preview do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level, :transcendence_step
|
||||
end
|
||||
|
||||
view :nested do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
|
||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level, :transcendence_step
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
|
|
|
|||
|
|
@ -3,47 +3,45 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridWeaponBlueprint < ApiBlueprint
|
||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||
|
||||
view :preview do
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :nested do
|
||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w|
|
||||
[
|
||||
{ modifier: w.ax_modifier1, strength: w.ax_strength1 },
|
||||
{ modifier: w.ax_modifier2, strength: w.ax_strength2 }
|
||||
]
|
||||
end
|
||||
|
||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(w.awakening),
|
||||
level: w.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
|
||||
if: ->(_field_name, w, _options) { w.weapon.present? }
|
||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
|
||||
association :weapon_keys,
|
||||
blueprint: WeaponKeyBlueprint,
|
||||
if: ->(_field_name, w, _options) {
|
||||
w.weapon.present? &&
|
||||
w.weapon.series.present? &&
|
||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
||||
if: lambda { |_field_name, w, _options|
|
||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
||||
}
|
||||
|
||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.ax } do |w|
|
||||
[
|
||||
{
|
||||
modifier: w.ax_modifier1,
|
||||
strength: w.ax_strength1
|
||||
},
|
||||
{
|
||||
modifier: w.ax_modifier2,
|
||||
strength: w.ax_strength2
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening_id } do |w|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(w.awakening),
|
||||
level: w.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
|
|
|
|||
|
|
@ -3,141 +3,105 @@
|
|||
module Api
|
||||
module V1
|
||||
class PartyBlueprint < ApiBlueprint
|
||||
# Base fields that are always needed
|
||||
fields :local_id, :description, :shortcode, :visibility,
|
||||
:name, :element, :extra, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count, :clear_time,
|
||||
:full_auto, :auto_guard, :auto_summon,
|
||||
:created_at, :updated_at
|
||||
|
||||
fields :local_id, :description, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count,
|
||||
:master_level, :ultimate_mastery
|
||||
|
||||
# Party associations
|
||||
association :user,
|
||||
blueprint: UserBlueprint,
|
||||
view: :minimal
|
||||
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
|
||||
association :raid,
|
||||
blueprint: RaidBlueprint,
|
||||
view: :nested
|
||||
|
||||
# Metadata associations
|
||||
field :favorited do |party, options|
|
||||
party.favorited?(options[:current_user])
|
||||
view :weapons do
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :summons do
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :characters do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :job_skills do
|
||||
field :job_skills do |job|
|
||||
{
|
||||
'0' => !job.skill0.nil? ? JobSkillBlueprint.render_as_hash(job.skill0) : nil,
|
||||
'1' => !job.skill1.nil? ? JobSkillBlueprint.render_as_hash(job.skill1) : nil,
|
||||
'2' => !job.skill2.nil? ? JobSkillBlueprint.render_as_hash(job.skill2) : nil,
|
||||
'3' => !job.skill3.nil? ? JobSkillBlueprint.render_as_hash(job.skill3) : nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :minimal do
|
||||
fields :name, :element, :shortcode, :favorited, :remix,
|
||||
:extra, :full_auto, :clear_time, :auto_guard, :auto_summon,
|
||||
:visibility, :created_at, :updated_at
|
||||
|
||||
field :guidebooks do |p|
|
||||
{
|
||||
'1' => !p.guidebook1.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook1) : nil,
|
||||
'2' => !p.guidebook2.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook2) : nil,
|
||||
'3' => !p.guidebook3.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
association :raid,
|
||||
blueprint: RaidBlueprint,
|
||||
view: :full
|
||||
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
|
||||
association :user,
|
||||
blueprint: UserBlueprint,
|
||||
view: :minimal
|
||||
end
|
||||
|
||||
view :jobs do
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
include_view :job_skills
|
||||
end
|
||||
|
||||
# For collection views
|
||||
view :preview do
|
||||
include_view :preview_objects # Characters, Weapons, Summons
|
||||
include_view :preview_metadata # Object counts
|
||||
include_view :minimal
|
||||
include_view :characters
|
||||
include_view :weapons
|
||||
include_view :summons
|
||||
end
|
||||
|
||||
# For object views
|
||||
view :full do
|
||||
# Primary object associations
|
||||
include_view :nested_objects # Characters, Weapons, Summons
|
||||
include_view :remix_metadata # Remixes, Source party
|
||||
include_view :job_metadata # Accessory, Skills, Guidebooks
|
||||
end
|
||||
include_view :preview
|
||||
include_view :summons
|
||||
include_view :characters
|
||||
include_view :job_skills
|
||||
|
||||
# Primary object associations
|
||||
view :preview_objects do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :preview
|
||||
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :preview
|
||||
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :preview
|
||||
end
|
||||
|
||||
view :nested_objects do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :nested
|
||||
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :nested
|
||||
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
# Metadata views
|
||||
view :preview_metadata do
|
||||
field :counts do |party|
|
||||
{
|
||||
weapons: party.weapons_count,
|
||||
characters: party.characters_count,
|
||||
summons: party.summons_count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :source_party do
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
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,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :preview
|
||||
end
|
||||
|
||||
# Job-related views
|
||||
view :job_metadata do
|
||||
field :job_skills, cache: true do |party|
|
||||
{
|
||||
'0' => party.skill0 ? JobSkillBlueprint.render_as_hash(party.skill0) : nil,
|
||||
'1' => party.skill1 ? JobSkillBlueprint.render_as_hash(party.skill1) : nil,
|
||||
'2' => party.skill2 ? JobSkillBlueprint.render_as_hash(party.skill2) : nil,
|
||||
'3' => party.skill3 ? JobSkillBlueprint.render_as_hash(party.skill3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
field :guidebooks, cache: true do |party|
|
||||
{
|
||||
'1' => party.guidebook1 ? GuidebookBlueprint.render_as_hash(party.guidebook1) : nil,
|
||||
'2' => party.guidebook2 ? GuidebookBlueprint.render_as_hash(party.guidebook2) : nil,
|
||||
'3' => party.guidebook3 ? GuidebookBlueprint.render_as_hash(party.guidebook3) : nil
|
||||
}
|
||||
end
|
||||
fields :local_id, :description, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count,
|
||||
:master_level, :ultimate_mastery
|
||||
|
||||
association :accessory,
|
||||
blueprint: JobAccessoryBlueprint,
|
||||
if: ->(_field_name, party, _options) { party.accessory_id.present? }
|
||||
blueprint: JobAccessoryBlueprint
|
||||
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :minimal
|
||||
|
||||
# TODO: This should probably be paginated
|
||||
association :remixes,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :collection
|
||||
end
|
||||
|
||||
view :collection do
|
||||
include_view :preview
|
||||
end
|
||||
|
||||
# Created view
|
||||
view :created do
|
||||
include_view :full
|
||||
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
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ module Api
|
|||
end
|
||||
|
||||
fields :slug, :level, :element
|
||||
|
||||
association :group, blueprint: RaidGroupBlueprint, view: :flat
|
||||
end
|
||||
|
||||
view :full do
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ module Api
|
|||
|
||||
view :full do
|
||||
include_view :flat
|
||||
association :raids, blueprint: RaidBlueprint, view: :nested
|
||||
association :raids, blueprint: RaidBlueprint, view: :full
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,55 +3,41 @@
|
|||
module Api
|
||||
module V1
|
||||
class SummonBlueprint < ApiBlueprint
|
||||
field :name do |s|
|
||||
field :name do |w|
|
||||
{
|
||||
en: s.name_en,
|
||||
ja: s.name_jp
|
||||
en: w.name_en,
|
||||
ja: w.name_jp
|
||||
}
|
||||
end
|
||||
|
||||
fields :granblue_id, :element, :rarity, :max_level
|
||||
|
||||
field :uncap do |s|
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: s.flb,
|
||||
ulb: s.ulb,
|
||||
transcendence: s.transcendence
|
||||
flb: w.flb,
|
||||
ulb: w.ulb,
|
||||
transcendence: w.transcendence
|
||||
}
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |s|
|
||||
{
|
||||
min_hp: s.min_hp,
|
||||
max_hp: s.max_hp,
|
||||
max_hp_flb: s.max_hp_flb,
|
||||
max_hp_ulb: s.max_hp_ulb,
|
||||
max_hp_xlb: s.max_hp_xlb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |s|
|
||||
{
|
||||
min_atk: s.min_atk,
|
||||
max_atk: s.max_atk,
|
||||
max_atk_flb: s.max_atk_flb,
|
||||
max_atk_ulb: s.max_atk_ulb,
|
||||
max_atk_xlb: s.max_atk_xlb
|
||||
}
|
||||
end
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb,
|
||||
max_hp_xlb: w.max_hp_xlb
|
||||
}
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
field :transcendence_date
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :dates
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb,
|
||||
max_atk_xlb: w.max_atk_xlb
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ module Api
|
|||
}
|
||||
end
|
||||
|
||||
# Primary information
|
||||
fields :granblue_id, :element, :proficiency,
|
||||
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
|
||||
:series, :ax, :ax_type
|
||||
|
||||
# Uncap information
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: w.flb,
|
||||
|
|
@ -24,39 +22,28 @@ module Api
|
|||
}
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb
|
||||
}
|
||||
end
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb
|
||||
}
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
field :transcendence_date
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :dates
|
||||
association :awakenings,
|
||||
blueprint: AwakeningBlueprint,
|
||||
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
|
||||
field :awakenings, if: ->(_fn, obj, _opt) { obj.awakenings.length.positive? } do |w|
|
||||
w.awakenings.map do |a|
|
||||
AwakeningBlueprint.render_as_hash(a)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ module Api
|
|||
##### Hooks
|
||||
before_action :current_user
|
||||
before_action :default_content_type
|
||||
around_action :n_plus_one_detection, unless: -> { Rails.env.production? }
|
||||
|
||||
##### Responders
|
||||
respond_to :json
|
||||
|
|
@ -86,7 +85,7 @@ module Api
|
|||
end
|
||||
|
||||
def render_unprocessable_entity_response(exception)
|
||||
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
|
||||
render json: ErrorBlueprint.render_as_json(nil, exception: exception),
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
|
@ -105,9 +104,9 @@ module Api
|
|||
|
||||
def render_not_found_response(object)
|
||||
render json: ErrorBlueprint.render(nil, error: {
|
||||
message: "#{object.capitalize} could not be found",
|
||||
code: 'not_found'
|
||||
}), status: :not_found
|
||||
message: "#{object.capitalize} could not be found",
|
||||
code: 'not_found'
|
||||
}), status: :not_found
|
||||
end
|
||||
|
||||
def render_unauthorized_response
|
||||
|
|
@ -120,13 +119,6 @@ module Api
|
|||
def restrict_access
|
||||
raise UnauthorizedError unless current_user
|
||||
end
|
||||
|
||||
def n_plus_one_detection
|
||||
Prosopite.scan
|
||||
yield
|
||||
ensure
|
||||
Prosopite.finish
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,115 +2,68 @@
|
|||
|
||||
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
|
||||
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]
|
||||
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_incoming_character, only: :create
|
||||
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
|
||||
before_action :find_current_characters, only: :create
|
||||
|
||||
##
|
||||
# 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
|
||||
processed_params = transform_character_params(character_params)
|
||||
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.
|
||||
|
||||
if conflict_characters.present?
|
||||
render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position])
|
||||
# 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
|
||||
else
|
||||
# Remove any existing grid character occupying the specified position.
|
||||
if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position]))
|
||||
existing.destroy
|
||||
# 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
|
||||
end
|
||||
|
||||
# Build the new grid character
|
||||
grid_character = build_new_grid_character(processed_params)
|
||||
# Then, create a new grid character
|
||||
character = GridCharacter.create!(character_params.merge(party_id: party.id,
|
||||
character_id: incoming_character.id))
|
||||
|
||||
if grid_character.save
|
||||
render json: GridCharacterBlueprint.render(grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested), status: :created
|
||||
else
|
||||
render_validation_error_response(grid_character)
|
||||
if character.save!
|
||||
grid_character_view = render_grid_character_view(character)
|
||||
render json: grid_character_view, status: :created
|
||||
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
|
||||
processed_params = transform_character_params(character_params)
|
||||
assign_raw_attributes(@grid_character)
|
||||
assign_transformed_attributes(@grid_character, processed_params)
|
||||
|
||||
if @grid_character.save
|
||||
render json: GridCharacterBlueprint.render(@grid_character,
|
||||
root: :grid_character,
|
||||
view: :nested)
|
||||
else
|
||||
render_validation_error_response(@grid_character)
|
||||
mastery = {}
|
||||
%i[ring1 ring2 ring3 ring4 earring awakening].each do |key|
|
||||
value = character_params.to_h[key]
|
||||
mastery[key] = value unless value.nil?
|
||||
end
|
||||
|
||||
@character.attributes = character_params.merge(mastery)
|
||||
|
||||
return render json: GridCharacterBlueprint.render(@character, view: :full) if @character.save
|
||||
|
||||
render_validation_error_response(@character)
|
||||
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_by(id: resolve_params[:incoming])
|
||||
render_not_found_response('character') and return unless incoming
|
||||
incoming = Character.find(resolve_params[:incoming])
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) }
|
||||
party = conflicting.first.party
|
||||
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
|
||||
conflicting.each(&:destroy)
|
||||
# Destroy each conflicting character
|
||||
conflicting.each { |character| GridCharacter.destroy(character.id) }
|
||||
|
||||
if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||
existing.destroy
|
||||
end
|
||||
# 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
|
||||
|
||||
# Compute the default uncap level based on the incoming character's flags.
|
||||
if incoming.special
|
||||
uncap_level = 3
|
||||
uncap_level = 5 if incoming.ulb
|
||||
|
|
@ -121,146 +74,33 @@ module Api
|
|||
uncap_level = 5 if incoming.flb
|
||||
end
|
||||
|
||||
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
|
||||
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!
|
||||
end
|
||||
|
||||
##
|
||||
# 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 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
|
||||
def 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
|
||||
render_unauthorized_response if @character.party.user != current_user
|
||||
return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @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
|
||||
@party.characters.where(character_id: @incoming_character.id).to_a
|
||||
@conflict_characters ||= find_conflict_characters(incoming_character)
|
||||
end
|
||||
|
||||
def find_conflict_characters(incoming_character)
|
||||
|
|
@ -282,124 +122,52 @@ module Api
|
|||
end.flatten
|
||||
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_by(id: params.dig(:character, :party_id)) ||
|
||||
Party.find_by(id: params[:party_id]) ||
|
||||
@grid_character&.party
|
||||
render_not_found_response('party') unless @party
|
||||
def set
|
||||
@character = GridCharacter.find(params[:id])
|
||||
end
|
||||
|
||||
##
|
||||
# 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
|
||||
|
||||
##
|
||||
# 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
|
||||
@incoming_character = Character.find(character_params[:character_id])
|
||||
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 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
|
||||
def find_party
|
||||
@party = Party.find(character_params[:party_id])
|
||||
render_unauthorized_response if current_user && (party.user != current_user)
|
||||
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
|
||||
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
|
||||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
end
|
||||
|
||||
##
|
||||
# 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.
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def character_params
|
||||
params.require(:character).permit(
|
||||
:id,
|
||||
:party_id,
|
||||
:character_id,
|
||||
:position,
|
||||
:uncap_level,
|
||||
:transcendence_step,
|
||||
:perpetuity,
|
||||
awakening: %i[id level],
|
||||
rings: %i[modifier strength],
|
||||
earring: %i[modifier strength]
|
||||
)
|
||||
params.require(:character).permit(: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])
|
||||
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,142 +2,82 @@
|
|||
|
||||
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 :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 :set, only: %w[update update_uncap_level update_quick_summon]
|
||||
before_action :find_party, only: :create
|
||||
before_action :find_incoming_summon, only: :create
|
||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
|
||||
before_action :authorize, 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
|
||||
# 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
|
||||
# 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?
|
||||
|
||||
# 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.
|
||||
if summon.validate
|
||||
save_summon(summon)
|
||||
else
|
||||
render_validation_error_response(grid_summon)
|
||||
handle_conflict(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
|
||||
@grid_summon.attributes = summon_params
|
||||
@summon.attributes = summon_params
|
||||
|
||||
return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save
|
||||
return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save
|
||||
|
||||
render_validation_error_response(@grid_summon)
|
||||
render_validation_error_response(@character)
|
||||
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 = @grid_summon.summon
|
||||
max_level = max_uncap_level(summon)
|
||||
summon = @summon.summon
|
||||
max_uncap_level = max_uncap_level(summon)
|
||||
|
||||
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?
|
||||
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?
|
||||
|
||||
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
|
||||
uncap_level = if greater_than_max_uncap || can_be_transcended
|
||||
max_uncap_level
|
||||
else
|
||||
summon_params[:uncap_level]
|
||||
end
|
||||
|
||||
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
|
||||
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)
|
||||
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?(@grid_summon.position)
|
||||
return if [4, 5, 6].include?(@summon.position)
|
||||
|
||||
quick_summons = @grid_summon.party.summons.select(&:quick_summon)
|
||||
quick_summons = @summon.party.summons.select(&:quick_summon)
|
||||
|
||||
quick_summons.each do |summon|
|
||||
summon.update!(quick_summon: false)
|
||||
end
|
||||
|
||||
@grid_summon.update!(quick_summon: summon_params[:quick_summon])
|
||||
return unless @grid_summon.persisted?
|
||||
@summon.update!(quick_summon: summon_params[:quick_summon])
|
||||
return unless @summon.persisted?
|
||||
|
||||
quick_summons -= [@grid_summon]
|
||||
summons = [@grid_summon] + quick_summons
|
||||
quick_summons -= [@summon]
|
||||
summons = [@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,
|
||||
|
|
@ -152,15 +92,6 @@ 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
|
||||
|
|
@ -174,96 +105,14 @@ 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
|
||||
|
|
@ -276,65 +125,35 @@ module Api
|
|||
end
|
||||
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
|
||||
def find_incoming_summon
|
||||
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
|
||||
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
|
||||
|
||||
render_unauthorized_response
|
||||
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)
|
||||
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)
|
||||
|
||||
render_unauthorized_response
|
||||
def render_grid_summon_view(grid_summon, conflict_position = nil)
|
||||
GridSummonBlueprint.render(grid_summon, view: :nested,
|
||||
root: :grid_summon,
|
||||
meta: { replaced: conflict_position })
|
||||
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)
|
||||
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
|
||||
end
|
||||
|
||||
##
|
||||
# Defines and permits the whitelisted parameters for a grid summon.
|
||||
#
|
||||
# @return [ActionController::Parameters] The permitted parameters.
|
||||
def set
|
||||
@summon = GridSummon.find_by('id = ?', summon_params[:id])
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def summon_params
|
||||
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend,
|
||||
:quick_summon, :uncap_level, :transcendence_step)
|
||||
|
|
|
|||
|
|
@ -2,143 +2,110 @@
|
|||
|
||||
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
|
||||
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]
|
||||
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]
|
||||
|
||||
##
|
||||
# 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
|
||||
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
|
||||
# Create the GridWeapon with the desired parameters
|
||||
weapon = GridWeapon.new
|
||||
weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id)
|
||||
|
||||
grid_weapon = GridWeapon.new(
|
||||
weapon_params.merge(
|
||||
party_id: @party.id,
|
||||
weapon_id: @incoming_weapon.id
|
||||
)
|
||||
)
|
||||
|
||||
if grid_weapon.valid?
|
||||
save_weapon(grid_weapon)
|
||||
if weapon.validate
|
||||
save_weapon(weapon)
|
||||
else
|
||||
if grid_weapon.errors[:series].include?('must not conflict with existing weapons')
|
||||
handle_conflict(grid_weapon)
|
||||
else
|
||||
render_validation_error_response(grid_weapon)
|
||||
end
|
||||
handle_conflict(weapon)
|
||||
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_by(id: resolve_params[:incoming])
|
||||
conflicting_ids = resolve_params[:conflicting]
|
||||
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
|
||||
incoming = Weapon.find(resolve_params[:incoming])
|
||||
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) }
|
||||
party = conflicting.first.party
|
||||
|
||||
# Destroy each conflicting weapon
|
||||
conflicting_weapons.each(&:destroy)
|
||||
conflicting.each { |weapon| GridWeapon.destroy(weapon.id) }
|
||||
|
||||
# Destroy the weapon at the desired position if it exists
|
||||
if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
|
||||
existing_weapon.destroy
|
||||
end
|
||||
existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first
|
||||
GridWeapon.destroy(existing_weapon.id) if existing_weapon
|
||||
|
||||
# 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
|
||||
)
|
||||
uncap_level = 3
|
||||
uncap_level = 4 if incoming.flb
|
||||
uncap_level = 5 if incoming.ulb
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
##
|
||||
# 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])
|
||||
render_unauthorized_response if @weapon.party.user != current_user
|
||||
return render json: GridCharacterBlueprint.render(@weapon, view: :destroyed) if @weapon.destroy
|
||||
end
|
||||
|
||||
return render_not_found_response('grid_weapon') if grid_weapon.nil?
|
||||
def update_uncap_level
|
||||
weapon = GridWeapon.find(weapon_params[:id])
|
||||
object = weapon.weapon
|
||||
max_uncap_level = max_uncap_level(object)
|
||||
|
||||
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
|
||||
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
|
||||
)
|
||||
|
||||
return unless weapon.persisted?
|
||||
|
||||
render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# 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)
|
||||
def max_uncap_level(weapon)
|
||||
if weapon.flb && !weapon.ulb && !weapon.transcendence
|
||||
4
|
||||
elsif weapon.ulb && !weapon.transcendence
|
||||
|
|
@ -150,213 +117,122 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
##
|
||||
# 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)
|
||||
def check_weapon_compatibility
|
||||
return if compatible_with_position?(incoming_weapon, weapon_params[:position])
|
||||
|
||||
raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: incoming_weapon)
|
||||
end
|
||||
|
||||
##
|
||||
# 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
|
||||
# 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)
|
||||
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 (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
|
||||
existing.destroy
|
||||
# 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)
|
||||
end
|
||||
|
||||
# Set the party's element if the grid weapon is being set as mainhand
|
||||
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!
|
||||
if weapon.position == -1
|
||||
party.element = weapon.weapon.element
|
||||
party.save!
|
||||
elsif [9, 10, 11].include?(weapon.position)
|
||||
party.extra = true
|
||||
party.save!
|
||||
end
|
||||
|
||||
if weapon.save
|
||||
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
|
||||
render json: output, status: :created
|
||||
else
|
||||
render_validation_error_response(weapon)
|
||||
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
|
||||
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 }
|
||||
|
||||
if conflict_weapon.nil?
|
||||
# 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
|
||||
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
|
||||
|
||||
##
|
||||
# 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)
|
||||
def set
|
||||
@weapon = GridWeapon.where('id = ?', params[:id]).first
|
||||
end
|
||||
|
||||
##
|
||||
# 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
|
||||
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
|
||||
end
|
||||
|
||||
##
|
||||
# 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.
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def weapon_params
|
||||
params.require(:weapon).permit(
|
||||
:id, :party_id, :weapon_id,
|
||||
|
|
@ -367,10 +243,6 @@ 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,20 +2,6 @@
|
|||
|
||||
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,
|
||||
|
|
@ -27,189 +13,263 @@ 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] Checking input...'
|
||||
Rails.logger.info "[IMPORT] Starting import..."
|
||||
|
||||
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
|
||||
# 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
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
Rails.logger.info '[IMPORT] Starting import...'
|
||||
# Transform game data
|
||||
transformer = ::Granblue::Transformers::BaseDeckTransformer.new(raw_params)
|
||||
transformed_data = transformer.transform
|
||||
Rails.logger.info "[IMPORT] Transformed data: #{transformed_data.inspect}"
|
||||
|
||||
return if performed? # Rendered an error response already
|
||||
# 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
|
||||
|
||||
party = Party.create(user: current_user)
|
||||
deck_data = raw_params['import']
|
||||
process_data(party, deck_data)
|
||||
# Create party
|
||||
party = Party.new(user: current_user)
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# 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
|
||||
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
|
||||
|
||||
Rails.logger.error "[IMPORT] Unauthorized access attempt by user #{current_user&.id}"
|
||||
render json: { error: 'Unauthorized' }, status: :unauthorized
|
||||
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
|
||||
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
|
||||
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
|
||||
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 }
|
||||
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
|
||||
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_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
|
||||
|
||||
Processors::JobProcessor.new(party, data).process
|
||||
Processors::CharacterProcessor.new(party, data).process
|
||||
Processors::SummonProcessor.new(party, data).process
|
||||
Processors::WeaponProcessor.new(party, data).process
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ module Api
|
|||
module V1
|
||||
class JobSkillsController < Api::V1::ApiController
|
||||
def all
|
||||
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
|
||||
render json: JobSkillBlueprint.render(JobSkill.all)
|
||||
end
|
||||
|
||||
def job
|
||||
@skills = JobSkill.includes(:job)
|
||||
.where.not(job_id: params[:id])
|
||||
.where(emp: true)
|
||||
@skills = JobSkill.where('job_id != ? AND emp = ?', params[:id], true)
|
||||
render json: JobSkillBlueprint.render(@skills)
|
||||
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: :job_metadata) if @party.save!
|
||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
|
||||
end
|
||||
|
||||
def update_job_skills
|
||||
|
|
|
|||
|
|
@ -2,197 +2,437 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
# Controller for managing party-related operations in the API
|
||||
# @api public
|
||||
class PartiesController < Api::V1::ApiController
|
||||
include PartyAuthorizationConcern
|
||||
include PartyQueryingConcern
|
||||
include PartyPreviewConcern
|
||||
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]
|
||||
|
||||
# Constants used for filtering validations.
|
||||
|
||||
# Maximum number of characters allowed in a party
|
||||
MAX_CHARACTERS = 5
|
||||
|
||||
# Maximum number of summons allowed in a party
|
||||
MAX_SUMMONS = 8
|
||||
|
||||
# Maximum number of weapons allowed in a party
|
||||
MAX_WEAPONS = 13
|
||||
|
||||
# Default minimum number of characters required for filtering
|
||||
DEFAULT_MIN_CHARACTERS = 3
|
||||
|
||||
# Default minimum number of summons required for filtering
|
||||
DEFAULT_MIN_SUMMONS = 2
|
||||
|
||||
# Default minimum number of weapons required for filtering
|
||||
DEFAULT_MIN_WEAPONS = 5
|
||||
|
||||
# Default maximum clear time in seconds
|
||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||
|
||||
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
|
||||
party = Party.new(party_params)
|
||||
party = Party.new
|
||||
party.user = current_user if current_user
|
||||
if party_params && party_params[:raid_id].present?
|
||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||
party.extra = raid.group.extra
|
||||
end
|
||||
party.attributes = party_params if party_params
|
||||
|
||||
if party_params && party_params[:raid_id]
|
||||
raid = Raid.find_by(id: party_params[:raid_id])
|
||||
party.extra = raid.group.extra
|
||||
end
|
||||
if party.save
|
||||
party.schedule_preview_generation if party.ready_for_preview?
|
||||
render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
|
||||
else
|
||||
render_validation_error_response(party)
|
||||
|
||||
if party.save!
|
||||
return render json: PartyBlueprint.render(party, view: :created, root: :party),
|
||||
status: :created
|
||||
end
|
||||
|
||||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# Shows a specific party.
|
||||
def show
|
||||
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')
|
||||
# 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
|
||||
end
|
||||
|
||||
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party
|
||||
|
||||
render_not_found_response('project')
|
||||
end
|
||||
|
||||
# Updates an existing party.
|
||||
def update
|
||||
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
||||
|
||||
if party_params && party_params[:raid_id]
|
||||
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)
|
||||
raid = Raid.find_by(id: party_params[:raid_id])
|
||||
@party.extra = raid.group.extra
|
||||
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.
|
||||
def destroy
|
||||
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
return render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
end
|
||||
|
||||
# Extended Party Actions
|
||||
|
||||
# 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] if party_params
|
||||
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?
|
||||
|
||||
if new_party.save
|
||||
new_party.schedule_preview_generation
|
||||
render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created
|
||||
render json: PartyBlueprint.render(new_party, view: :created, root: :party),
|
||||
status: :created
|
||||
else
|
||||
render_validation_error_response(new_party)
|
||||
end
|
||||
end
|
||||
|
||||
# Lists parties based on query parameters.
|
||||
def index
|
||||
query = build_filtered_query(build_common_base_query)
|
||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
render_paginated_parties(@parties)
|
||||
conditions = build_filters
|
||||
|
||||
query = build_query(conditions)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
|
||||
@parties = fetch_parties(query)
|
||||
count = calculate_count(query)
|
||||
total_pages = calculate_total_pages(count)
|
||||
|
||||
render_party_json(@parties, count, total_pages)
|
||||
end
|
||||
|
||||
# GET /api/v1/parties/favorites
|
||||
def favorites
|
||||
raise Api::V1::UnauthorizedError unless current_user
|
||||
|
||||
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)
|
||||
render_paginated_parties(@parties)
|
||||
conditions = build_filters
|
||||
conditions[:favorites] = { user_id: current_user.id }
|
||||
|
||||
query = build_query(conditions, favorites: true)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
|
||||
@parties = fetch_parties(query)
|
||||
count = calculate_count(query)
|
||||
total_pages = calculate_total_pages(count)
|
||||
|
||||
render_party_json(@parties, count, total_pages)
|
||||
end
|
||||
|
||||
# Preview Management
|
||||
|
||||
# Serves the party's preview image
|
||||
# @return [void]
|
||||
# Serves the party's preview image.
|
||||
def preview
|
||||
party_preview(@party)
|
||||
end
|
||||
|
||||
# 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? }
|
||||
|
||||
preview_service = PreviewService::Coordinator.new(party)
|
||||
redirect_to preview_service.preview_url
|
||||
end
|
||||
|
||||
# Forces regeneration of the party preview.
|
||||
def regenerate_preview
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
return render_unauthorized_response unless current_user && party.user_id == current_user.id
|
||||
|
||||
# Ensure only party owner can force regeneration
|
||||
unless current_user && party.user_id == current_user.id
|
||||
return render_unauthorized_response
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
# Loads the party by its shortcode.
|
||||
def set_from_slug
|
||||
@party = Party.includes(
|
||||
: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
|
||||
).find_by(shortcode: params[:id])
|
||||
render_not_found_response('party') unless @party
|
||||
def authorize
|
||||
return unless not_owner && !admin_mode
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def build_filters
|
||||
params = request.params
|
||||
|
||||
start_time = build_start_time(params['recency'])
|
||||
|
||||
min_characters_count = build_count(params['characters_count'], DEFAULT_MIN_CHARACTERS)
|
||||
min_summons_count = build_count(params['summons_count'], DEFAULT_MIN_SUMMONS)
|
||||
min_weapons_count = build_count(params['weapons_count'], DEFAULT_MIN_WEAPONS)
|
||||
max_clear_time = build_max_clear_time(params['max_clear_time'])
|
||||
|
||||
{
|
||||
element: build_element(params['element']),
|
||||
raid: params['raid'],
|
||||
created_at: params['recency'].present? ? start_time..DateTime.current : nil,
|
||||
full_auto: build_option(params['full_auto']),
|
||||
auto_guard: build_option(params['auto_guard']),
|
||||
charge_attack: build_option(params['charge_attack']),
|
||||
characters_count: min_characters_count..MAX_CHARACTERS,
|
||||
summons_count: min_summons_count..MAX_SUMMONS,
|
||||
weapons_count: min_weapons_count..MAX_WEAPONS
|
||||
}.delete_if { |_k, v| v.nil? }
|
||||
end
|
||||
|
||||
def build_start_time(recency)
|
||||
return unless recency.present?
|
||||
|
||||
(DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day
|
||||
end
|
||||
|
||||
def build_count(value, default)
|
||||
value.blank? ? default : value.to_i
|
||||
end
|
||||
|
||||
def build_max_clear_time(value)
|
||||
value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i
|
||||
end
|
||||
|
||||
def build_element(element)
|
||||
element.to_i unless element.blank?
|
||||
end
|
||||
|
||||
def build_option(value)
|
||||
value.to_i unless value.blank? || value.to_i == -1
|
||||
end
|
||||
|
||||
def build_query(conditions, favorites: false)
|
||||
query = Party.distinct
|
||||
.joins(weapons: [:object], summons: [:object], characters: [:object])
|
||||
.group('parties.id')
|
||||
.where(conditions)
|
||||
.where(privacy(favorites: favorites))
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
|
||||
query = query.joins(:favorites) if favorites
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def includes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
|
||||
end
|
||||
|
||||
def excludes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
|
||||
end
|
||||
|
||||
def apply_includes(query, includes)
|
||||
included = includes.split(',')
|
||||
includes_condition = included.map { |id| includes(id) }.join(' AND ')
|
||||
query.where(includes_condition)
|
||||
end
|
||||
|
||||
def apply_excludes(query, _excludes)
|
||||
characters_subquery = excluded_characters.select(1).arel
|
||||
summons_subquery = excluded_summons.select(1).arel
|
||||
weapons_subquery = excluded_weapons.select(1).arel
|
||||
|
||||
query.where(characters_subquery.exists.not)
|
||||
.where(weapons_subquery.exists.not)
|
||||
.where(summons_subquery.exists.not)
|
||||
end
|
||||
|
||||
def excluded_characters
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '3' }
|
||||
GridCharacter.joins(:object)
|
||||
.where(characters: { granblue_id: excluded })
|
||||
.where('grid_characters.party_id = parties.id')
|
||||
end
|
||||
|
||||
def excluded_summons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '2' }
|
||||
GridSummon.joins(:object)
|
||||
.where(summons: { granblue_id: excluded })
|
||||
.where('grid_summons.party_id = parties.id')
|
||||
end
|
||||
|
||||
def excluded_weapons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '1' }
|
||||
GridWeapon.joins(:object)
|
||||
.where(weapons: { granblue_id: excluded })
|
||||
.where('grid_weapons.party_id = parties.id')
|
||||
end
|
||||
|
||||
def fetch_parties(query)
|
||||
query.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false }
|
||||
end
|
||||
|
||||
def calculate_count(query)
|
||||
query.count.values.sum
|
||||
end
|
||||
|
||||
def calculate_total_pages(count)
|
||||
count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
|
||||
end
|
||||
|
||||
def render_party_json(parties, count, total_pages)
|
||||
render json: PartyBlueprint.render(parties,
|
||||
view: :collection,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
})
|
||||
end
|
||||
|
||||
def privacy(favorites: false)
|
||||
return if admin_mode
|
||||
|
||||
if favorites
|
||||
'visibility < 3'
|
||||
else
|
||||
'visibility = 1'
|
||||
end
|
||||
end
|
||||
|
||||
def user_quality
|
||||
return if request.params[:user_quality].blank? || request.params[:user_quality] == 'false'
|
||||
|
||||
'user_id IS NOT NULL'
|
||||
end
|
||||
|
||||
def name_quality
|
||||
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(',')
|
||||
|
||||
return if request.params[:name_quality].blank? || request.params[:name_quality] == 'false'
|
||||
|
||||
"name NOT IN (#{joined_names})"
|
||||
end
|
||||
|
||||
def original
|
||||
return if request.params['original'].blank? || request.params['original'] == 'false'
|
||||
|
||||
'source_party_id IS NULL'
|
||||
end
|
||||
|
||||
def id_to_table(id)
|
||||
case id[0]
|
||||
when '3'
|
||||
table = 'characters'
|
||||
when '2'
|
||||
table = 'summons'
|
||||
when '1'
|
||||
table = 'weapons'
|
||||
end
|
||||
|
||||
table
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def set_from_slug
|
||||
@party = Party.where('shortcode = ?', params[:id]).first
|
||||
if @party
|
||||
@party.favorited = current_user && @party ? @party.is_favorited(current_user) : false
|
||||
else
|
||||
render_not_found_response('party')
|
||||
end
|
||||
end
|
||||
|
||||
# Loads the party by its id.
|
||||
def set
|
||||
@party = Party.where('id = ?', params[:id]).first
|
||||
end
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ module Api
|
|||
module V1
|
||||
class RaidsController < Api::V1::ApiController
|
||||
def all
|
||||
render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested)
|
||||
render json: RaidBlueprint.render(Raid.all, view: :full)
|
||||
end
|
||||
|
||||
def show
|
||||
|
|
@ -13,7 +13,7 @@ module Api
|
|||
end
|
||||
|
||||
def groups
|
||||
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full)
|
||||
render json: RaidGroupBlueprint.render(RaidGroup.all, view: :full)
|
||||
end
|
||||
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[update]
|
||||
before_action :set_by_id, only: %w[info update]
|
||||
|
||||
MAX_CHARACTERS = 5
|
||||
MAX_SUMMONS = 8
|
||||
|
|
@ -55,39 +55,32 @@ module Api
|
|||
if @user.nil?
|
||||
render_not_found_response('user')
|
||||
else
|
||||
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
|
||||
conditions = build_conditions
|
||||
conditions[:user_id] = @user.id
|
||||
|
||||
parties = Party
|
||||
.where(conditions)
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each do |party|
|
||||
party.favorited = current_user ? party.is_favorited(current_user) : false
|
||||
end
|
||||
|
||||
count = Party.where(conditions).count
|
||||
|
||||
render json: UserBlueprint.render(@user,
|
||||
view: :profile,
|
||||
root: 'profile',
|
||||
parties: parties,
|
||||
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
|
||||
current_user: current_user
|
||||
)
|
||||
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
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -103,42 +96,12 @@ 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
|
||||
|
||||
unless params['recency'].blank?
|
||||
start_time = (DateTime.current - params['recency'].to_i.seconds)
|
||||
.to_datetime.beginning_of_day
|
||||
.to_datetime.beginning_of_day
|
||||
end
|
||||
|
||||
min_characters_count = params['characters_count'].blank? ? DEFAULT_MIN_CHARACTERS : params['characters_count'].to_i
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -12,11 +12,10 @@ class GeneratePartyPreviewJob < ApplicationJob
|
|||
around_perform :track_timing
|
||||
|
||||
def perform(party_id)
|
||||
# Log start of job processing
|
||||
Rails.logger.info("Starting preview generation for party #{party_id}")
|
||||
Rails.logger.info("Debug: should_generate? check starting")
|
||||
|
||||
party = Party.find(party_id)
|
||||
Rails.logger.info("Party found: #{party.inspect}")
|
||||
|
||||
if party.preview_state == 'generated' &&
|
||||
party.preview_generated_at &&
|
||||
|
|
@ -26,24 +25,8 @@ class GeneratePartyPreviewJob < ApplicationJob
|
|||
end
|
||||
|
||||
begin
|
||||
Rails.logger.info("Initializing PreviewService::Coordinator")
|
||||
service = PreviewService::Coordinator.new(party)
|
||||
Rails.logger.info("Coordinator initialized")
|
||||
|
||||
Rails.logger.info("Checking should_generate?")
|
||||
should_gen = service.send(:should_generate?)
|
||||
Rails.logger.info("should_generate? returned: #{should_gen}")
|
||||
|
||||
if !should_gen
|
||||
Rails.logger.info("Not generating preview because should_generate? returned false")
|
||||
Rails.logger.info("Preview state: #{party.preview_state}")
|
||||
Rails.logger.info("Generation in progress: #{service.send(:generation_in_progress?)}")
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info("Starting generate_preview")
|
||||
result = service.generate_preview
|
||||
Rails.logger.info("Generate preview result: #{result}")
|
||||
|
||||
if result
|
||||
Rails.logger.info("Successfully generated preview for party #{party_id}")
|
||||
|
|
@ -53,10 +36,9 @@ class GeneratePartyPreviewJob < ApplicationJob
|
|||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Error generating preview for party #{party_id}: #{e.message}")
|
||||
Rails.logger.error("Full error details:")
|
||||
Rails.logger.error(e.full_message)
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
notify_failure(party, e)
|
||||
raise
|
||||
raise # Allow retry mechanism to handle the error
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Awakening < ApplicationRecord
|
||||
has_many :weapon_awakenings, foreign_key: :awakening_id
|
||||
has_many :weapons, through: :weapon_awakenings
|
||||
def weapon_awakenings
|
||||
WeaponAwakening.where(awakening_id: id)
|
||||
end
|
||||
|
||||
def weapons
|
||||
weapon_awakenings.map(&:weapon)
|
||||
end
|
||||
|
||||
def awakening
|
||||
AwakeningBlueprint
|
||||
|
|
|
|||
|
|
@ -34,13 +34,6 @@ class Character < ApplicationRecord
|
|||
}
|
||||
}
|
||||
|
||||
AWAKENINGS = [
|
||||
{ slug: 'character-balanced', name_en: 'Balanced', name_jp: 'バランス', order: 0 },
|
||||
{ slug: 'character-atk', name_en: 'Attack', name_jp: '攻撃', order: 1 },
|
||||
{ slug: 'character-def', name_en: 'Defense', name_jp: '防御', order: 2 },
|
||||
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
|
||||
].freeze
|
||||
|
||||
def blueprint
|
||||
CharacterBlueprint
|
||||
end
|
||||
|
|
@ -48,4 +41,73 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
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,44 +1,19 @@
|
|||
# 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
|
||||
has_one :object, class_name: 'Character', foreign_key: :id, primary_key: :character_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
|
||||
|
||||
# Virtual attributes
|
||||
attr_accessor :new_rings
|
||||
attr_accessor :new_awakening
|
||||
validate :over_mastery_attack_matches_hp, on: :update
|
||||
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
|
|
@ -50,121 +25,49 @@ 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
|
||||
|
||||
##
|
||||
# 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
|
||||
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')
|
||||
end
|
||||
|
||||
##
|
||||
# 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.
|
||||
def validate_over_mastery_values
|
||||
[ring1, ring2, ring3, ring4].each_with_index do |ring, index|
|
||||
next if ring['modifier'].nil?
|
||||
|
||||
modifier = over_mastery_modifiers[ring['modifier']]
|
||||
# Use a helper to add errors if the value is out-of-range.
|
||||
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
|
||||
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?
|
||||
|
||||
|
|
@ -175,72 +78,22 @@ 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 character
|
||||
Character.find(character_id)
|
||||
end
|
||||
|
||||
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 }
|
||||
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)
|
||||
self.ring1 = rings_array[0]
|
||||
self.ring2 = rings_array[1]
|
||||
self.ring3 = rings_array[2]
|
||||
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 } }
|
||||
|
|
@ -259,10 +112,6 @@ 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',
|
||||
|
|
@ -283,10 +132,6 @@ 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],
|
||||
|
|
@ -307,9 +152,6 @@ 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',
|
||||
|
|
@ -325,10 +167,6 @@ 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: {
|
||||
|
|
@ -374,18 +212,10 @@ 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,52 +1,24 @@
|
|||
# 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
|
||||
|
||||
belongs_to :party,
|
||||
counter_cache: :summons_count,
|
||||
inverse_of: :summons
|
||||
validates_presence_of :party
|
||||
has_one :object, class_name: 'Summon', foreign_key: :id, primary_key: :summon_id
|
||||
|
||||
# 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 summon
|
||||
Summon.find(summon_id)
|
||||
end
|
||||
|
||||
def blueprint
|
||||
GridSummonBlueprint
|
||||
end
|
||||
|
||||
##
|
||||
# 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.
|
||||
# Returns conflicting summons if they exist
|
||||
def conflicts(party)
|
||||
return unless summon.limit
|
||||
|
||||
|
|
@ -59,74 +31,13 @@ class GridSummon < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
##
|
||||
# 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]
|
||||
# Validates whether there is a conflict with the party
|
||||
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 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]
|
||||
# Validates whether the summon can be added to the desired position
|
||||
def compatible_with_position
|
||||
return unless [4, 5].include?(position.to_i) && !summon.subaura
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +1,13 @@
|
|||
# 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,
|
||||
counter_cache: :weapons_count,
|
||||
inverse_of: :weapons
|
||||
validates_presence_of :party
|
||||
|
||||
has_one :object, class_name: 'Weapon', foreign_key: :id, primary_key: :weapon_id
|
||||
|
||||
belongs_to :weapon_key1, class_name: 'WeaponKey', foreign_key: :weapon_key1_id, optional: true
|
||||
belongs_to :weapon_key2, class_name: 'WeaponKey', foreign_key: :weapon_key2_id, optional: true
|
||||
belongs_to :weapon_key3, class_name: 'WeaponKey', foreign_key: :weapon_key3_id, optional: true
|
||||
|
|
@ -39,14 +15,10 @@ 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 :assign_mainhand
|
||||
before_save :mainhand?
|
||||
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
|
|
@ -56,99 +28,73 @@ class GridWeapon < ApplicationRecord
|
|||
nullify :ax_strength2
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint for rendering the grid weapon.
|
||||
#
|
||||
# @return [GridWeaponBlueprint] the blueprint class for grid weapons.
|
||||
# Helper methods
|
||||
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
|
||||
Weapon.find(weapon_id)
|
||||
end
|
||||
|
||||
def weapon_keys
|
||||
[weapon_key1, weapon_key2, weapon_key3].compact
|
||||
end
|
||||
|
||||
##
|
||||
# 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).
|
||||
# Returns conflicting weapons if they exist
|
||||
def conflicts(party)
|
||||
return [] unless weapon.present? && weapon.respond_to?(:limit) && weapon.limit
|
||||
return unless weapon.limit
|
||||
|
||||
party.weapons.select do |party_weapon|
|
||||
# Skip if the record is not persisted.
|
||||
next false unless party_weapon.id.present?
|
||||
conflicting_weapons = []
|
||||
|
||||
party.weapons.each do |party_weapon|
|
||||
next unless party_weapon.id
|
||||
|
||||
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?
|
||||
|
||||
(series_match || both_opus_or_draconic || both_draconic) && !id_match
|
||||
conflicting_weapons << party_weapon if (series_match || both_opus_or_draconic || both_draconic) && !id_match
|
||||
end
|
||||
|
||||
conflicting_weapons
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# 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
|
||||
return unless weapon.present?
|
||||
# Conflict management methods
|
||||
|
||||
if EXTRA_POSITIONS.include?(position.to_i) && !ALLOWED_EXTRA_SERIES.include?(weapon.series.to_i)
|
||||
errors.add(:series, 'must be compatible with position')
|
||||
end
|
||||
# Validates whether the weapon can be added to the desired position
|
||||
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 is_extra_position
|
||||
|
||||
return true if is_extra_weapon
|
||||
|
||||
errors.add(:series, 'must be compatible with position')
|
||||
false
|
||||
end
|
||||
|
||||
##
|
||||
# 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]
|
||||
# Validates whether the desired weapon key can be added to the weapon
|
||||
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 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]
|
||||
# Validates whether there is a conflict with the party
|
||||
def no_conflicts
|
||||
conflicting = conflicts(party)
|
||||
errors.add(:series, 'must not conflict with existing weapons') if conflicting.any?
|
||||
# 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')
|
||||
end
|
||||
|
||||
##
|
||||
# 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)
|
||||
# Checks if the weapon should be a mainhand before saving the model
|
||||
def mainhand?
|
||||
self.mainhand = position == -1
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class Job < ApplicationRecord
|
||||
include PgSearch::Model
|
||||
|
||||
belongs_to :party, optional: true
|
||||
belongs_to :party
|
||||
has_many :skills, class_name: 'JobSkill'
|
||||
|
||||
multisearchable against: %i[name_en name_jp],
|
||||
|
|
|
|||
|
|
@ -1,95 +1,15 @@
|
|||
# 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
|
||||
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
|
||||
##### ActiveRecord Associations
|
||||
belongs_to :source_party,
|
||||
class_name: 'Party',
|
||||
foreign_key: :source_party_id,
|
||||
optional: true
|
||||
|
||||
has_many :remixes, -> { order(created_at: :desc) },
|
||||
has_many :derivative_parties,
|
||||
class_name: 'Party',
|
||||
foreign_key: 'source_party_id',
|
||||
foreign_key: :source_party_id,
|
||||
inverse_of: :source_party,
|
||||
dependent: :nullify
|
||||
|
||||
|
|
@ -140,21 +60,18 @@ class Party < ApplicationRecord
|
|||
has_many :characters,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridCharacter',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :weapons,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridWeapon',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :summons,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridSummon',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
|
|
@ -167,10 +84,7 @@ class Party < ApplicationRecord
|
|||
before_create :set_shortcode
|
||||
before_create :set_edit_key
|
||||
|
||||
after_commit :update_element!, on: %i[create update]
|
||||
after_commit :update_extra!, on: %i[create update]
|
||||
|
||||
# Amoeba configuration
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
set weapons_count: 0
|
||||
set characters_count: 0
|
||||
|
|
@ -185,303 +99,105 @@ class Party < ApplicationRecord
|
|||
include_association :summons
|
||||
end
|
||||
|
||||
# ActiveRecord Validations
|
||||
##### ActiveRecord Validations
|
||||
validate :skills_are_unique
|
||||
validate :guidebooks_are_unique
|
||||
|
||||
# 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
|
||||
attr_accessor :favorited
|
||||
|
||||
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
|
||||
self.enum :preview_state, {
|
||||
pending: 0, # Never generated
|
||||
queued: 1, # Generation job scheduled
|
||||
generated: 2, # Has preview image
|
||||
failed: 3 # Generation failed
|
||||
}
|
||||
|
||||
# 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_regeneration, if: :preview_relevant_changes?
|
||||
|
||||
after_commit :schedule_preview_generation, if: :should_generate_preview?
|
||||
def is_favorited(user)
|
||||
user.favorite_parties.include? self if user
|
||||
end
|
||||
|
||||
#########################
|
||||
# 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?
|
||||
def is_remix
|
||||
!source_party.nil?
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the blueprint class used for rendering the party.
|
||||
#
|
||||
# @return [Class] the PartyBlueprint class.
|
||||
def remixes
|
||||
Party.where(source_party_id: id)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
##
|
||||
# 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
|
||||
user.favorite_parties.include?(self)
|
||||
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?
|
||||
|
||||
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 %w[queued in_progress].include?(preview_state.to_s)
|
||||
|
||||
update_column(:preview_state, self.class.preview_states[:queued])
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
#########################
|
||||
# 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
|
||||
|
||||
##
|
||||
# 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
|
||||
|
||||
##
|
||||
# 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
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
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')
|
||||
end
|
||||
|
||||
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')
|
||||
end
|
||||
|
||||
def preview_relevant_changes?
|
||||
return false if preview_state == 'queued'
|
||||
|
||||
(saved_changes.keys & %w[name job_id element weapons_count characters_count summons_count]).any?
|
||||
end
|
||||
|
||||
def schedule_preview_regeneration
|
||||
# Cancel any pending jobs
|
||||
GeneratePartyPreviewJob.cancel_scheduled_jobs(party_id: id)
|
||||
|
||||
# Mark as pending
|
||||
update_column(:preview_state, :pending)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,54 +37,6 @@ 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
|
||||
|
|
@ -99,23 +51,11 @@ class Weapon < ApplicationRecord
|
|||
|
||||
# Returns whether the weapon is included in the Draconic or Dark Opus series
|
||||
def opus_or_draconic?
|
||||
[3, 27].include?(series)
|
||||
[2, 3].include?(series)
|
||||
end
|
||||
|
||||
# Returns whether the weapon belongs to the Draconic Weapon series or the Draconic Weapon Providence series
|
||||
def draconic_or_providence?
|
||||
[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]
|
||||
[3, 34].include?(series)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,4 +3,12 @@
|
|||
class WeaponAwakening < ApplicationRecord
|
||||
belongs_to :weapon
|
||||
belongs_to :awakening
|
||||
|
||||
def weapon
|
||||
Weapon.find(weapon_id)
|
||||
end
|
||||
|
||||
def awakening
|
||||
Awakening.find(awakening_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'aws-sdk-s3'
|
||||
|
||||
class AwsService
|
||||
attr_reader :s3_client, :bucket
|
||||
|
||||
class ConfigurationError < StandardError; end
|
||||
|
||||
def initialize
|
||||
Rails.logger.info "Environment: #{Rails.env}"
|
||||
|
||||
# Try different methods of getting credentials
|
||||
creds = get_credentials
|
||||
Rails.logger.info "Credentials source: #{creds[:source]}"
|
||||
validate_credentials!
|
||||
|
||||
@s3_client = Aws::S3::Client.new(
|
||||
region: creds[:region],
|
||||
access_key_id: creds[:access_key_id],
|
||||
secret_access_key: creds[:secret_access_key]
|
||||
region: Rails.application.credentials.dig(:aws, :region),
|
||||
access_key_id: Rails.application.credentials.dig(:aws, :access_key_id),
|
||||
secret_access_key: Rails.application.credentials.dig(:aws, :secret_access_key)
|
||||
)
|
||||
@bucket = creds[:bucket_name]
|
||||
@bucket = Rails.application.credentials.dig(:aws, :bucket_name)
|
||||
rescue KeyError => e
|
||||
raise ConfigurationError, "Missing AWS credential: #{e.message}"
|
||||
end
|
||||
|
|
@ -40,59 +40,30 @@ class AwsService
|
|||
|
||||
private
|
||||
|
||||
def get_credentials
|
||||
# Try Rails credentials first
|
||||
rails_creds = Rails.application.credentials.dig(:aws)
|
||||
if rails_creds&.dig(:access_key_id)
|
||||
Rails.logger.info "Using Rails credentials"
|
||||
return rails_creds.merge(source: 'rails_credentials')
|
||||
def credentials
|
||||
@credentials ||= begin
|
||||
creds = Rails.application.credentials[:aws]
|
||||
raise ConfigurationError, 'AWS credentials not found' unless creds
|
||||
|
||||
{
|
||||
region: creds[:region],
|
||||
access_key_id: creds[:access_key_id],
|
||||
secret_access_key: creds[:secret_access_key],
|
||||
bucket_name: creds[:bucket_name]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def validate_credentials!
|
||||
missing = []
|
||||
creds = Rails.application.credentials[:aws]
|
||||
|
||||
%i[region access_key_id secret_access_key bucket_name].each do |key|
|
||||
missing << key unless creds&.dig(key)
|
||||
end
|
||||
|
||||
# Try string keys
|
||||
rails_creds = Rails.application.credentials.dig('aws')
|
||||
if rails_creds&.dig('access_key_id')
|
||||
Rails.logger.info "Using Rails credentials (string keys)"
|
||||
return {
|
||||
region: rails_creds['region'],
|
||||
access_key_id: rails_creds['access_key_id'],
|
||||
secret_access_key: rails_creds['secret_access_key'],
|
||||
bucket_name: rails_creds['bucket_name'],
|
||||
source: 'rails_credentials_string'
|
||||
}
|
||||
end
|
||||
return unless missing.any?
|
||||
|
||||
# Try environment variables
|
||||
if ENV['AWS_ACCESS_KEY_ID']
|
||||
Rails.logger.info "Using environment variables"
|
||||
return {
|
||||
region: ENV['AWS_REGION'],
|
||||
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
|
||||
secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
|
||||
bucket_name: ENV['AWS_BUCKET_NAME'],
|
||||
source: 'environment'
|
||||
}
|
||||
end
|
||||
|
||||
# Try alternate environment variable names
|
||||
if ENV['RAILS_AWS_ACCESS_KEY_ID']
|
||||
Rails.logger.info "Using Rails-prefixed environment variables"
|
||||
return {
|
||||
region: ENV['RAILS_AWS_REGION'],
|
||||
access_key_id: ENV['RAILS_AWS_ACCESS_KEY_ID'],
|
||||
secret_access_key: ENV['RAILS_AWS_SECRET_ACCESS_KEY'],
|
||||
bucket_name: ENV['RAILS_AWS_BUCKET_NAME'],
|
||||
source: 'rails_environment'
|
||||
}
|
||||
end
|
||||
|
||||
validate_credentials = ->(creds, source) {
|
||||
missing = []
|
||||
%i[region access_key_id secret_access_key bucket_name].each do |key|
|
||||
missing << key unless creds[key].present?
|
||||
end
|
||||
raise ConfigurationError, "Missing AWS credentials from #{source}: #{missing.join(', ')}" if missing.any?
|
||||
}
|
||||
|
||||
raise ConfigurationError, "No AWS credentials found in any location"
|
||||
raise ConfigurationError, "Missing AWS credentials: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,251 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -15,59 +15,23 @@ module PreviewService
|
|||
end
|
||||
|
||||
def create_blank_canvas(width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, color: DEFAULT_BACKGROUND_COLOR)
|
||||
Rails.logger.info("Creating blank canvas #{width}x#{height}")
|
||||
temp_file = Tempfile.new(%w[canvas .png])
|
||||
Rails.logger.info("Temp file created at: #{temp_file.path}")
|
||||
|
||||
begin
|
||||
Rails.logger.info("Checking ImageMagick setup...")
|
||||
version = `which convert`
|
||||
Rails.logger.info("ImageMagick convert path: #{version}")
|
||||
|
||||
Rails.logger.info("Executing convert command...")
|
||||
MiniMagick::Tool::Convert.new do |convert|
|
||||
convert.size "#{width}x#{height}"
|
||||
convert << "xc:#{color}"
|
||||
convert << temp_file.path
|
||||
end
|
||||
Rails.logger.info("Convert command completed successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to create canvas with convert: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("PATH: #{ENV['PATH']}")
|
||||
Rails.logger.error("LD_LIBRARY_PATH: #{ENV['LD_LIBRARY_PATH']}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise
|
||||
MiniMagick::Tool::Convert.new do |convert|
|
||||
convert.size "#{width}x#{height}"
|
||||
convert << "xc:#{color}"
|
||||
convert << temp_file.path
|
||||
end
|
||||
|
||||
Rails.logger.info("Canvas created successfully at: #{temp_file.path}")
|
||||
temp_file
|
||||
end
|
||||
|
||||
def add_text(image, party_name, job_icon: nil, user: nil, **options)
|
||||
party_name = party_name.to_s.strip
|
||||
party_name = 'Untitled' if party_name.empty?
|
||||
|
||||
font_size = options.fetch(:size, '32')
|
||||
font_color = options.fetch(:color, 'white')
|
||||
|
||||
# Try multiple font locations
|
||||
font_locations = [
|
||||
Rails.root.join('app', 'assets', 'fonts', 'Gk-Bd.otf').to_s,
|
||||
Rails.root.join('public', 'assets', 'fonts', 'Gk-Bd.otf').to_s
|
||||
]
|
||||
|
||||
@font_path = font_locations.find { |path| File.exist?(path) }
|
||||
|
||||
unless @font_path
|
||||
Rails.logger.error("Font file not found in any location: #{font_locations.join(', ')}")
|
||||
raise "Font file not found"
|
||||
end
|
||||
|
||||
Rails.logger.info("Using font path: #{@font_path}")
|
||||
unless File.exist?(@font_path)
|
||||
Rails.logger.error("Font file not found at: #{@font_path}")
|
||||
raise "Font file not found"
|
||||
end
|
||||
# Load custom font for username, for later use
|
||||
@font_path ||= Rails.root.join('app', 'assets', 'fonts', 'Gk-Bd.otf').to_s
|
||||
|
||||
# Measure party name text size
|
||||
text_metrics = measure_text(party_name, font_size)
|
||||
|
|
@ -108,18 +72,16 @@ module PreviewService
|
|||
end
|
||||
|
||||
def draw_party_name(image, party_name, text_metrics, job_icon, font_color, font_size)
|
||||
# Determine x position based on presence of job_icon
|
||||
text_x = job_icon ? PADDING + 64 + 16 : PADDING
|
||||
text_y = PADDING + text_metrics[:height]
|
||||
|
||||
image.combine_options do |c|
|
||||
c.gravity 'NorthWest'
|
||||
c.fill font_color
|
||||
c.font @font_path
|
||||
c.fill font_color
|
||||
c.pointsize font_size
|
||||
# Escape quotes and use pango markup for better text handling
|
||||
c.annotate "0x0+#{text_x}+#{text_y}", party_name.gsub('"', '\"')
|
||||
c.draw "text #{text_x},#{text_y} '#{party_name}'"
|
||||
end
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
|
|
@ -170,13 +132,7 @@ module PreviewService
|
|||
image
|
||||
end
|
||||
|
||||
def measure_text(text, font_size, font: @font_path)
|
||||
# Ensure text is not empty and is properly escaped
|
||||
text = text.to_s.strip
|
||||
text = 'Untitled' if text.empty?
|
||||
|
||||
# Escape text for shell command
|
||||
escaped_text = text.gsub(/'/, "'\\\\''")
|
||||
def measure_text(text, font_size, font: 'Arial')
|
||||
|
||||
# Create a temporary file for the text measurement
|
||||
temp_file = Tempfile.new(['text_measure', '.png'])
|
||||
|
|
@ -189,7 +145,7 @@ module PreviewService
|
|||
'-fill', 'black',
|
||||
'-font', font,
|
||||
'-pointsize', font_size.to_s,
|
||||
"label:'#{escaped_text}'", # Quote the text
|
||||
"label:#{text}",
|
||||
temp_file.path
|
||||
]
|
||||
|
||||
|
|
@ -203,15 +159,15 @@ module PreviewService
|
|||
height: image.height,
|
||||
width: image.width
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "Text measurement error: #{e.message}"
|
||||
# Fallback dimensions
|
||||
{ height: 50, width: 200 }
|
||||
ensure
|
||||
# Close and unlink the temporary file
|
||||
temp_file.close
|
||||
temp_file.unlink
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Text measurement error: #{e.message}"
|
||||
# Fallback dimensions
|
||||
{ height: 50, width: 200 }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ module PreviewService
|
|||
PREVIEW_EXPIRY = 30.days
|
||||
GENERATION_TIMEOUT = 5.minutes
|
||||
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
|
||||
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
|
||||
|
||||
# Public Interface - Core Operations
|
||||
|
||||
# Initialize the party preview service
|
||||
#
|
||||
|
|
@ -41,59 +38,25 @@ module PreviewService
|
|||
return false unless should_generate?
|
||||
|
||||
begin
|
||||
Rails.logger.info("🖼️ Starting preview generation for party #{@party.id}")
|
||||
|
||||
Rails.logger.info("🖼️ Updating party state to in_progress")
|
||||
@party.update!(preview_state: :in_progress)
|
||||
Rails.logger.info("Starting preview generation for party #{@party.id}")
|
||||
set_generation_in_progress
|
||||
|
||||
Rails.logger.info("🖼️ Checking ImageMagick installation...")
|
||||
begin
|
||||
version = `convert -version`
|
||||
Rails.logger.info("🖼️ ImageMagick version: #{version}")
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}")
|
||||
end
|
||||
# Generate the preview image
|
||||
image = create_preview_image
|
||||
save_preview(image)
|
||||
|
||||
Rails.logger.info("🖼️ Creating preview image...")
|
||||
begin
|
||||
image = create_preview_image
|
||||
Rails.logger.info("🖼️ Preview image created successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Failed to create preview image: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Saving preview...")
|
||||
begin
|
||||
save_preview(image)
|
||||
Rails.logger.info("🖼️ Preview saved successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Failed to save preview: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Updating party state...")
|
||||
# Update party state
|
||||
@party.update!(
|
||||
preview_state: :generated,
|
||||
preview_generated_at: Time.current
|
||||
)
|
||||
Rails.logger.info("🖼️ Party state updated successfully")
|
||||
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("🖼️ Stack trace:")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
handle_preview_generation_error(e)
|
||||
false
|
||||
ensure
|
||||
Rails.logger.info("🖼️ Cleaning up resources...")
|
||||
@image_fetcher.cleanup
|
||||
clear_generation_in_progress
|
||||
Rails.logger.info("🖼️ Cleanup completed")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -106,8 +69,6 @@ module PreviewService
|
|||
end
|
||||
|
||||
# Deletes the existing preview image for the party
|
||||
#
|
||||
# @return [void]
|
||||
def delete_preview
|
||||
if Rails.env.production?
|
||||
delete_s3_preview
|
||||
|
|
@ -123,164 +84,9 @@ module PreviewService
|
|||
Rails.logger.error("Failed to delete preview for party #{@party.id}: #{e.message}")
|
||||
end
|
||||
|
||||
# State Management - Public
|
||||
|
||||
# Determines if a new preview should be generated
|
||||
#
|
||||
# @return [Boolean] True if a new preview should be generated, false otherwise
|
||||
def should_generate?
|
||||
Rails.logger.info("🖼️ Checking should_generate? conditions")
|
||||
|
||||
unless @party.ready_for_preview?
|
||||
Rails.logger.info("🖼️ Party not ready for preview (insufficient content)")
|
||||
return false
|
||||
end
|
||||
|
||||
if generation_in_progress?
|
||||
Rails.logger.info("🖼️ Generation already in progress, returning false")
|
||||
return false
|
||||
end
|
||||
|
||||
Rails.logger.info("🖼️ Preview state: #{@party.preview_state}")
|
||||
|
||||
case @party.preview_state
|
||||
when 'pending', 'queued'
|
||||
Rails.logger.info("🖼️ State is #{@party.preview_state}, will generate")
|
||||
true
|
||||
when 'in_progress'
|
||||
Rails.logger.info("🖼️ State is in_progress, skipping generation")
|
||||
false
|
||||
when 'failed'
|
||||
should_retry = @party.preview_generated_at.nil? ||
|
||||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
|
||||
Rails.logger.info("🖼️ Failed state, should retry: #{should_retry}")
|
||||
should_retry
|
||||
when 'generated'
|
||||
expired = @party.preview_expired?
|
||||
changed = @party.preview_content_changed?
|
||||
debounced = @party.preview_generated_at.nil? ||
|
||||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
|
||||
|
||||
should_regenerate = expired || (changed && debounced)
|
||||
|
||||
Rails.logger.info("🖼️ Generated state check - expired: #{expired}, content changed: #{changed}, debounced: #{debounced}")
|
||||
Rails.logger.info("🖼️ Should regenerate: #{should_regenerate}")
|
||||
|
||||
should_regenerate
|
||||
else
|
||||
Rails.logger.info("🖼️ Unknown state, will generate")
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a preview generation is currently in progress
|
||||
#
|
||||
# @return [Boolean] True if a preview is being generated, false otherwise
|
||||
def generation_in_progress?
|
||||
in_progress = Rails.cache.exist?("party_preview_generating_#{@party.id}")
|
||||
Rails.logger.info("Cache key check for generation_in_progress: #{in_progress}")
|
||||
in_progress
|
||||
end
|
||||
|
||||
# Retrieves the S3 object for the party's preview image
|
||||
#
|
||||
# @return [Aws::S3::Types::GetObjectOutput] S3 object containing the preview image
|
||||
# @raise [Aws::S3::Errors::NoSuchKey] If the preview image doesn't exist in S3
|
||||
# @raise [Aws::S3::Errors::NoSuchBucket] If the configured bucket doesn't exist
|
||||
def get_s3_object
|
||||
@aws_service.s3_client.get_object(
|
||||
bucket: @aws_service.bucket,
|
||||
key: preview_key
|
||||
)
|
||||
end
|
||||
|
||||
# Schedules a background job to generate the preview
|
||||
#
|
||||
# @return [void]
|
||||
def schedule_generation
|
||||
GeneratePartyPreviewJob
|
||||
.set(wait: 30.seconds)
|
||||
.perform_later(@party.id)
|
||||
|
||||
@party.update!(preview_state: :queued)
|
||||
end
|
||||
|
||||
# Returns the full path for storing preview images locally
|
||||
#
|
||||
# @return [Pathname] Full path where the preview image should be stored
|
||||
def local_preview_path
|
||||
LOCAL_STORAGE_PATH.join(preview_filename)
|
||||
end
|
||||
|
||||
# Creates the preview image for the party
|
||||
#
|
||||
# @return [MiniMagick::Image] The generated preview image
|
||||
def create_preview_image
|
||||
Rails.logger.info("Creating blank canvas...")
|
||||
begin
|
||||
canvas = @canvas_service.create_blank_canvas
|
||||
Rails.logger.info("Canvas created at: #{canvas.path}")
|
||||
image = MiniMagick::Image.new(canvas.path)
|
||||
Rails.logger.info("MiniMagick image object created")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to create canvas: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
# Add more detailed logging for job icon handling
|
||||
Rails.logger.info("Processing job icon...")
|
||||
job_icon = nil
|
||||
if @party.job.present?
|
||||
Rails.logger.info("Job present: #{@party.job.inspect}")
|
||||
Rails.logger.info("Fetching job icon for job ID: #{@party.job.granblue_id}")
|
||||
begin
|
||||
job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id)
|
||||
Rails.logger.info("Job icon fetched successfully") if job_icon
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to fetch job icon: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
# Don't raise this error, just log it and continue without the job icon
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
Rails.logger.info("Adding party name and job icon...")
|
||||
text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user)
|
||||
image = text_result[:image]
|
||||
Rails.logger.info("Text and icon added successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to add text/icon: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
begin
|
||||
Rails.logger.info("Calculating grid layout...")
|
||||
grid_layout = @grid_service.calculate_layout(
|
||||
canvas_height: Canvas::PREVIEW_HEIGHT,
|
||||
title_bottom_y: text_result[:text_bottom_y]
|
||||
)
|
||||
Rails.logger.info("Grid layout calculated")
|
||||
|
||||
Rails.logger.info("Drawing weapons...")
|
||||
Rails.logger.info("Weapons count: #{@party.weapons.count}")
|
||||
image = organize_and_draw_weapons(image, grid_layout)
|
||||
Rails.logger.info("Weapons drawn successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed during weapons drawing: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Sets up the appropriate storage system based on environment
|
||||
#
|
||||
# @return [void]
|
||||
def setup_storage
|
||||
# Always initialize AWS service for potential image fetching
|
||||
@aws_service = AwsService.new
|
||||
|
|
@ -289,7 +95,35 @@ module PreviewService
|
|||
FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s)
|
||||
end
|
||||
|
||||
# Image Generation Pipeline
|
||||
# Creates the preview image for the party
|
||||
#
|
||||
# @return [MiniMagick::Image] The generated preview image
|
||||
def create_preview_image
|
||||
# Create blank canvas
|
||||
canvas = @canvas_service.create_blank_canvas
|
||||
image = MiniMagick::Image.new(canvas.path)
|
||||
|
||||
# Fetch job icon
|
||||
job_icon = nil
|
||||
if @party.job.present?
|
||||
job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id)
|
||||
end
|
||||
|
||||
# Add party name with job icon
|
||||
text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user)
|
||||
image = text_result[:image]
|
||||
|
||||
# Calculate grid layout
|
||||
grid_layout = @grid_service.calculate_layout(
|
||||
canvas_height: Canvas::PREVIEW_HEIGHT,
|
||||
title_bottom_y: text_result[:text_bottom_y]
|
||||
)
|
||||
|
||||
# Organize and draw weapons
|
||||
image = organize_and_draw_weapons(image, grid_layout)
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
# Adds the job icon to the preview image
|
||||
#
|
||||
|
|
@ -307,7 +141,6 @@ module PreviewService
|
|||
# Organizes and draws weapons on the preview image
|
||||
#
|
||||
# @param image [MiniMagick::Image] The base image
|
||||
# @param grid_layout [Hash] The layout configuration for the grid
|
||||
# @return [MiniMagick::Image] The updated image with weapons
|
||||
def organize_and_draw_weapons(image, grid_layout)
|
||||
mainhand_weapon = @party.weapons.find(&:mainhand)
|
||||
|
|
@ -344,12 +177,9 @@ module PreviewService
|
|||
end
|
||||
end
|
||||
|
||||
# Storage Operations
|
||||
|
||||
# Saves the preview image to the appropriate storage system
|
||||
#
|
||||
# @param image [MiniMagick::Image] The image to save
|
||||
# @return [void]
|
||||
def save_preview(image)
|
||||
if Rails.env.production?
|
||||
upload_to_s3(image)
|
||||
|
|
@ -361,26 +191,20 @@ module PreviewService
|
|||
# Uploads the preview image to S3
|
||||
#
|
||||
# @param image [MiniMagick::Image] The image to upload
|
||||
# @return [void]
|
||||
def upload_to_s3(image)
|
||||
temp_file = Tempfile.new(%w[preview .png])
|
||||
temp_file = Tempfile.new(['preview', '.png'])
|
||||
begin
|
||||
image.write(temp_file.path)
|
||||
|
||||
# Use fixed key without timestamp
|
||||
key = "#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
|
||||
|
||||
File.open(temp_file.path, 'rb') do |file|
|
||||
@aws_service.s3_client.put_object(
|
||||
bucket: @aws_service.bucket,
|
||||
key: key,
|
||||
bucket: S3_BUCKET,
|
||||
key: preview_key,
|
||||
body: file,
|
||||
content_type: 'image/png',
|
||||
acl: 'private'
|
||||
)
|
||||
end
|
||||
|
||||
@party.update!(preview_s3_key: key)
|
||||
ensure
|
||||
temp_file.close
|
||||
temp_file.unlink
|
||||
|
|
@ -390,18 +214,29 @@ module PreviewService
|
|||
# Saves the preview image to local storage
|
||||
#
|
||||
# @param image [MiniMagick::Image] The image to save
|
||||
# @return [void]
|
||||
def save_to_local_storage(image)
|
||||
# Remove any existing previews for this party
|
||||
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
|
||||
File.delete(file)
|
||||
end
|
||||
|
||||
# Save new version
|
||||
image.write(local_preview_path)
|
||||
end
|
||||
|
||||
# Path & URL Generation
|
||||
|
||||
# Generates a filename for the preview image
|
||||
# Generates a timestamped filename for the preview image
|
||||
#
|
||||
# @return [String] Filename for the preview image
|
||||
# @return [String] Filename in format "shortcode_YYYYMMDDHHMMSS.png"
|
||||
def preview_filename
|
||||
"#{@party.shortcode}.png"
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
"#{@party.shortcode}_#{timestamp}.png"
|
||||
end
|
||||
|
||||
# Returns the full path for storing preview images locally
|
||||
#
|
||||
# @return [Pathname] Full path where the preview image should be stored
|
||||
def local_preview_path
|
||||
LOCAL_STORAGE_PATH.join(preview_filename)
|
||||
end
|
||||
|
||||
# Returns the URL for accessing locally stored preview images
|
||||
|
|
@ -422,8 +257,6 @@ module PreviewService
|
|||
"#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
|
||||
end
|
||||
|
||||
# Preview State Management
|
||||
|
||||
# Checks if a preview image exists for the party
|
||||
#
|
||||
# @return [Boolean] True if a preview exists, false otherwise
|
||||
|
|
@ -431,9 +264,10 @@ module PreviewService
|
|||
return false unless @party.preview_state == 'generated'
|
||||
|
||||
if Rails.env.production?
|
||||
@aws_service.file_exists?(preview_key)
|
||||
@aws_service.s3_client.head_object(bucket: S3_BUCKET, key: preview_key)
|
||||
true
|
||||
else
|
||||
!Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}.png").to_s).empty?
|
||||
!Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).empty?
|
||||
end
|
||||
rescue Aws::S3::Errors::NotFound
|
||||
false
|
||||
|
|
@ -446,15 +280,34 @@ module PreviewService
|
|||
signer = Aws::S3::Presigner.new(client: @aws_service.s3_client)
|
||||
signer.presigned_url(
|
||||
:get_object,
|
||||
bucket: @aws_service.bucket,
|
||||
bucket: S3_BUCKET,
|
||||
key: preview_key,
|
||||
expires_in: 1.hour.to_i
|
||||
expires_in: 1.hour
|
||||
)
|
||||
end
|
||||
|
||||
# Marks the preview generation as in progress
|
||||
# Determines if a new preview should be generated
|
||||
#
|
||||
# @return [void]
|
||||
# @return [Boolean] True if a new preview should be generated, false otherwise
|
||||
def should_generate?
|
||||
return false if generation_in_progress?
|
||||
return true if @party.preview_state.in?(['pending', 'failed'])
|
||||
|
||||
if @party.preview_state == 'generated'
|
||||
return @party.preview_generated_at < PREVIEW_EXPIRY.ago
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Checks if a preview generation is currently in progress
|
||||
#
|
||||
# @return [Boolean] True if a preview is being generated, false otherwise
|
||||
def generation_in_progress?
|
||||
Rails.cache.exist?("party_preview_generating_#{@party.id}")
|
||||
end
|
||||
|
||||
# Marks the preview generation as in progress
|
||||
def set_generation_in_progress
|
||||
Rails.cache.write(
|
||||
"party_preview_generating_#{@party.id}",
|
||||
|
|
@ -464,15 +317,18 @@ module PreviewService
|
|||
end
|
||||
|
||||
# Clears the in-progress flag for preview generation
|
||||
#
|
||||
# @return [void]
|
||||
def clear_generation_in_progress
|
||||
Rails.cache.delete("party_preview_generating_#{@party.id}")
|
||||
end
|
||||
|
||||
# Job Scheduling
|
||||
# Schedules a background job to generate the preview
|
||||
def schedule_generation
|
||||
GeneratePartyPreviewJob
|
||||
.set(wait: 30.seconds)
|
||||
.perform_later(@party.id)
|
||||
|
||||
# URL Generation
|
||||
@party.update!(preview_state: :queued)
|
||||
end
|
||||
|
||||
# Provides a default preview URL based on party attributes
|
||||
#
|
||||
|
|
@ -485,48 +341,29 @@ module PreviewService
|
|||
end
|
||||
end
|
||||
|
||||
# Cleanup Operations
|
||||
|
||||
# Deletes the preview from S3
|
||||
#
|
||||
# @return [void]
|
||||
def delete_s3_preview
|
||||
@aws_service.s3_client.delete_object(
|
||||
bucket: @aws_service.bucket,
|
||||
bucket: S3_BUCKET,
|
||||
key: preview_key
|
||||
)
|
||||
end
|
||||
|
||||
def self.cleanup_stalled_jobs
|
||||
Party.where(preview_state: :in_progress)
|
||||
.where('updated_at < ?', 10.minutes.ago)
|
||||
.update_all(preview_state: :pending)
|
||||
end
|
||||
|
||||
# Deletes local preview files
|
||||
#
|
||||
# @return [void]
|
||||
def delete_local_previews
|
||||
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
|
||||
File.delete(file)
|
||||
end
|
||||
end
|
||||
|
||||
# Error Handling
|
||||
|
||||
# Handles errors during preview generation
|
||||
#
|
||||
# @param error [Exception] The error that occurred
|
||||
# @return [void]
|
||||
def handle_preview_generation_error(error)
|
||||
Rails.logger.error("Preview generation failed for party #{@party.id}")
|
||||
Rails.logger.error("Error: #{error.class} - #{error.message}")
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
|
||||
@party.update_columns(
|
||||
preview_state: 'failed',
|
||||
preview_generated_at: Time.current
|
||||
)
|
||||
Rails.logger.error("Backtrace:\n#{error.backtrace.join("\n")}")
|
||||
@party.update!(preview_state: :failed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
require 'sidekiq/api'
|
||||
|
||||
# app/services/preview_generation_monitor.rb
|
||||
module PreviewService
|
||||
class GenerationMonitor
|
||||
class PreviewGenerationMonitor
|
||||
class << self
|
||||
def check_stalled_jobs
|
||||
Party.where(preview_state: :queued)
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,395 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,42 +1,42 @@
|
|||
require_relative "boot"
|
||||
|
||||
require "rails"
|
||||
# Pick the frameworks you want:
|
||||
require "active_model/railtie"
|
||||
require "active_job/railtie"
|
||||
require "active_record/railtie"
|
||||
require "active_storage/engine"
|
||||
require "action_controller/railtie"
|
||||
# require "action_mailer/railtie"
|
||||
# require "action_mailbox/engine"
|
||||
require "action_text/engine"
|
||||
require "action_view/railtie"
|
||||
require "action_cable/engine"
|
||||
require "rails/test_unit/railtie"
|
||||
|
||||
# Include only the Rails frameworks we need
|
||||
require "active_model/railtie" # Basic model functionality
|
||||
require "active_job/railtie" # Background job processing
|
||||
require "active_record/railtie" # Database support
|
||||
require "active_storage/engine" # File upload and storage
|
||||
require "action_controller/railtie" # API controller support
|
||||
require "action_text/engine" # Rich text handling
|
||||
require "action_view/railtie" # View rendering (needed for some API responses)
|
||||
require "rails/test_unit/railtie" # Testing framework
|
||||
|
||||
# Load gems from Gemfile
|
||||
# Require the gems listed in Gemfile, including any gems
|
||||
# you've limited to :test, :development, or :production.
|
||||
Bundler.require(*Rails.groups)
|
||||
|
||||
module HenseiApi
|
||||
class Application < Rails::Application
|
||||
# Use Rails 7.0 defaults
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 7.0
|
||||
|
||||
# Configure autoloading
|
||||
# Configuration for the application, engines, and railties goes here.
|
||||
#
|
||||
# These settings can be overridden in specific environments using the files
|
||||
# in config/environments, which are processed later.
|
||||
#
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
config.autoload_paths << Rails.root.join("lib")
|
||||
config.eager_load_paths << Rails.root.join("lib")
|
||||
|
||||
# Configure asset handling for API mode
|
||||
config.paths["app/assets"] ||= []
|
||||
config.paths["app/assets"].unshift(Rails.root.join("app", "assets").to_s)
|
||||
config.assets.paths << Rails.root.join("app", "assets", "fonts")
|
||||
|
||||
# Enable query logging
|
||||
config.active_record.query_log_tags_enabled = true
|
||||
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
|
||||
# Only loads a smaller set of middleware suitable for API only apps.
|
||||
# Middleware like session, flash, cookies can be added back manually.
|
||||
# Skip views, helpers and assets when generating a new resource.
|
||||
config.api_only = true
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -76,8 +76,4 @@ Rails.application.configure do
|
|||
# This makes it easy to tag log lines with debug information like subdomain and request id -
|
||||
# both very helpful in debugging multi-user production applications.
|
||||
config.log_tags = [:request_id]
|
||||
|
||||
config.after_initialize do
|
||||
Prosopite.rails_logger = true
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ require "active_support/core_ext/integer/time"
|
|||
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
config.require_master_key = true
|
||||
|
||||
|
||||
# Code is not reloaded between requests.
|
||||
config.cache_classes = true
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = false
|
||||
config.cache_classes = true
|
||||
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# class CacheFreeLogger < ActiveSupport::Logger
|
||||
# def add(severity, message = nil, progname = nil, &block)
|
||||
# return true if progname&.include? 'CACHE'
|
||||
#
|
||||
# super
|
||||
# end
|
||||
# end
|
||||
#
|
||||
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
||||
# ActiveRecord::Base.logger.level = 1
|
||||
class CacheFreeLogger < ActiveSupport::Logger
|
||||
def add(severity, message = nil, progname = nil, &block)
|
||||
return true if progname&.include? 'CACHE'
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Base.logger = CacheFreeLogger.new($stdout)
|
||||
ActiveRecord::Base.logger.level = 1
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
Rails.application.config.assets.precompile += %w( .otf )
|
||||
|
||||
# Ensure fonts directory exists in production
|
||||
fonts_dir = Rails.root.join('public', 'assets', 'fonts')
|
||||
FileUtils.mkdir_p(fonts_dir) unless File.directory?(fonts_dir)
|
||||
|
||||
# Copy fonts to public directory in production
|
||||
if Rails.env.production?
|
||||
Dir[Rails.root.join('app', 'assets', 'fonts', '*')].each do |font|
|
||||
FileUtils.cp(font, fonts_dir) if File.file?(font)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
Rails.application.config.after_initialize do
|
||||
Rails.logger.info "Initializing AWS Service..."
|
||||
begin
|
||||
AwsService.new
|
||||
Rails.logger.info "AWS Service initialized successfully"
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to initialize AWS Service: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Explicitly configure Oj to mimic JSON.
|
||||
Oj::Rails.mimic_JSON
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# Fetch environment variables with defaults if not set
|
||||
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost')
|
||||
redis_port = ENV.fetch('REDISPORT', '6379')
|
||||
|
||||
# Combine URL and port (adjust the path/DB as needed)
|
||||
full_redis_url = "#{redis_url}/0"
|
||||
|
||||
# Initialize Redis using the constructed URL
|
||||
$redis = Redis.new(url: full_redis_url)
|
||||
|
|
@ -5,14 +5,14 @@ unless defined?(Rails::Console) || Rails.env.test? || File.split($0).last == 'ra
|
|||
scheduler = Rufus::Scheduler.new
|
||||
|
||||
scheduler.every '5m' do
|
||||
PreviewService::GenerationMonitor.check_stalled_jobs
|
||||
PreviewGenerationMonitor.check_stalled_jobs
|
||||
end
|
||||
|
||||
scheduler.every '1h' do
|
||||
PreviewService::GenerationMonitor.retry_failed
|
||||
PreviewGenerationMonitor.retry_failed
|
||||
end
|
||||
|
||||
scheduler.every '1d' do
|
||||
PreviewService::GenerationMonitor.cleanup_old_previews
|
||||
PreviewGenerationMonitor.cleanup_old_previews
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sentry.init do |config|
|
||||
config.breadcrumbs_logger = [:active_support_logger]
|
||||
config.dsn = ENV['SENTRY_DSN']
|
||||
config.enable_tracing = true
|
||||
end
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: redis_url }
|
||||
config.death_handlers << ->(job, ex) do
|
||||
Rails.logger.error("Preview generation job #{job['jid']} failed with: #{ex.message}")
|
||||
end
|
||||
end
|
||||
|
||||
Sidekiq.configure_client do |config|
|
||||
config.redis = { url: redis_url }
|
||||
end
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
#
|
||||
# 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,79 +4,75 @@ Rails.application.routes.draw do
|
|||
skip_controllers :applications, :authorized_applications
|
||||
end
|
||||
|
||||
path_prefix = Rails.env.production? ? '/v1' : '/api/v1'
|
||||
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]
|
||||
|
||||
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]
|
||||
get 'version', to: 'api#version'
|
||||
|
||||
get 'version', to: 'api#version'
|
||||
post 'import', to: 'import#create'
|
||||
|
||||
post 'import', to: 'import#create'
|
||||
post 'import/weapons', to: 'import#weapons'
|
||||
post 'import/summons', to: 'import#summons'
|
||||
post 'import/characters', to: 'import#characters'
|
||||
get 'users/info/:id', to: 'users#info'
|
||||
|
||||
get 'users/info/:id', to: 'users#info'
|
||||
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 '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'
|
||||
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'
|
||||
|
||||
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 'check/email', to: 'users#check_email'
|
||||
post 'check/username', to: 'users#check_username'
|
||||
|
||||
post 'check/email', to: 'users#check_email'
|
||||
post 'check/username', to: 'users#check_username'
|
||||
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 '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', to: 'jobs#all'
|
||||
|
||||
get 'jobs', to: 'jobs#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 '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 'guidebooks', to: 'guidebooks#all'
|
||||
|
||||
get 'guidebooks', to: 'guidebooks#all'
|
||||
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 'raids', to: 'raids#all'
|
||||
get 'raids/groups', to: 'raids#groups'
|
||||
get 'raids/:id', to: 'raids#show'
|
||||
get 'weapon_keys', to: 'weapon_keys#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'
|
||||
|
||||
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 '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 '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 '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 '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'
|
||||
delete 'favorites', to: 'favorites#destroy'
|
||||
end
|
||||
end
|
||||
|
||||
if Rails.env.development?
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
:scheduler:
|
||||
cleanup_party_previews:
|
||||
cron: '0 0 * * *' # Daily at midnight
|
||||
class: CleanupPartyPreviewsJob
|
||||
queue: maintenance
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# 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: 20250218025755)
|
||||
DataMigrate::Data.define(version: 20250115094623)
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
class AddPreviewS3KeyToParties < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :parties, :preview_s3_key, :string
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
class SetPreviewStateDefaultToPending < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
Party.where(preview_state: nil).find_each do |party|
|
||||
party.update_column(:preview_state, :pending)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
class AddMissingIndexesToParties < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :parties, :visibility
|
||||
add_index :parties, :element
|
||||
add_index :parties, :created_at
|
||||
add_index :parties, [:weapons_count, :characters_count, :summons_count],
|
||||
name: 'index_parties_on_counters'
|
||||
add_index :parties, [:visibility, :created_at],
|
||||
name: 'index_parties_on_visibility_created_at'
|
||||
add_index :parties, :shortcode
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
class AddMissingIndexesToGridObjects < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :parties, :raid_id unless index_exists?(:parties, :raid_id)
|
||||
add_index :characters, :granblue_id unless index_exists?(:characters, :granblue_id)
|
||||
add_index :summons, :granblue_id unless index_exists?(:summons, :granblue_id)
|
||||
add_index :weapons, :granblue_id unless index_exists?(:weapons, :granblue_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
class CreatePgheroQueryStats < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :pghero_query_stats do |t|
|
||||
t.text :database
|
||||
t.text :user
|
||||
t.text :query
|
||||
t.integer :query_hash, limit: 8
|
||||
t.float :total_time
|
||||
t.integer :calls, limit: 8
|
||||
t.timestamp :captured_at
|
||||
end
|
||||
|
||||
add_index :pghero_query_stats, [:database, :captured_at]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
class EnablePgStatements < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
execute 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements;'
|
||||
end
|
||||
|
||||
def down
|
||||
execute 'DROP EXTENSION IF EXISTS pg_stat_statements;'
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
class RemoveUnusedIndex < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_index :parties, :visibility
|
||||
end
|
||||
end
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
class AddOptimizedIndexesToParties < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# Add composite index for grid positions since we order by these
|
||||
add_index :grid_weapons, [:party_id, :position], name: 'index_grid_weapons_on_party_id_and_position'
|
||||
add_index :grid_characters, [:party_id, :position], name: 'index_grid_characters_on_party_id_and_position'
|
||||
add_index :grid_summons, [:party_id, :position], name: 'index_grid_summons_on_party_id_and_position'
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
class AddNewSeriesToWeapons < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :weapons, :new_series, :integer
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
class MoveNewSeriesToSeries < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_column :weapons, :series
|
||||
rename_column :weapons, :new_series, :series
|
||||
end
|
||||
end
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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
|
||||
45
db/schema.rb
45
db/schema.rb
|
|
@ -10,13 +10,13 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_18_135254) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "uuid-ossp"
|
||||
|
||||
create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t|
|
||||
t.string "update_type", null: false
|
||||
|
|
@ -67,10 +67,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) 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
|
||||
|
||||
|
|
@ -133,7 +129,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
t.integer "awakening_level", default: 1
|
||||
t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id"
|
||||
t.index ["character_id"], name: "index_grid_characters_on_character_id"
|
||||
t.index ["party_id", "position"], name: "index_grid_characters_on_party_id_and_position"
|
||||
t.index ["party_id"], name: "index_grid_characters_on_party_id"
|
||||
end
|
||||
|
||||
|
|
@ -148,7 +143,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.integer "transcendence_step", default: 0, null: false
|
||||
t.boolean "quick_summon", default: false
|
||||
t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position"
|
||||
t.index ["party_id"], name: "index_grid_summons_on_party_id"
|
||||
t.index ["summon_id"], name: "index_grid_summons_on_summon_id"
|
||||
end
|
||||
|
|
@ -174,7 +168,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
t.integer "transcendence_step", default: 0
|
||||
t.string "weapon_key4_id"
|
||||
t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id"
|
||||
t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position"
|
||||
t.index ["party_id"], name: "index_grid_weapons_on_party_id"
|
||||
t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id"
|
||||
t.index ["weapon_key1_id"], name: "index_grid_weapons_on_weapon_key1_id"
|
||||
|
|
@ -309,26 +302,19 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
t.integer "visibility", default: 1, null: false
|
||||
t.integer "preview_state", default: 0, null: false
|
||||
t.datetime "preview_generated_at"
|
||||
t.string "preview_s3_key"
|
||||
t.index ["accessory_id"], name: "index_parties_on_accessory_id"
|
||||
t.index ["created_at"], name: "index_parties_on_created_at"
|
||||
t.index ["element"], name: "index_parties_on_element"
|
||||
t.index ["guidebook1_id"], name: "index_parties_on_guidebook1_id"
|
||||
t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id"
|
||||
t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id"
|
||||
t.index ["job_id"], name: "index_parties_on_job_id"
|
||||
t.index ["preview_generated_at"], name: "index_parties_on_preview_generated_at"
|
||||
t.index ["preview_state"], name: "index_parties_on_preview_state"
|
||||
t.index ["raid_id"], name: "index_parties_on_raid_id"
|
||||
t.index ["shortcode"], name: "index_parties_on_shortcode"
|
||||
t.index ["skill0_id"], name: "index_parties_on_skill0_id"
|
||||
t.index ["skill1_id"], name: "index_parties_on_skill1_id"
|
||||
t.index ["skill2_id"], name: "index_parties_on_skill2_id"
|
||||
t.index ["skill3_id"], name: "index_parties_on_skill3_id"
|
||||
t.index ["source_party_id"], name: "index_parties_on_source_party_id"
|
||||
t.index ["user_id"], name: "index_parties_on_user_id"
|
||||
t.index ["visibility", "created_at"], name: "index_parties_on_visibility_created_at"
|
||||
t.index ["weapons_count", "characters_count", "summons_count"], name: "index_parties_on_counters"
|
||||
end
|
||||
|
||||
create_table "pg_search_documents", force: :cascade do |t|
|
||||
|
|
@ -344,17 +330,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
|
||||
end
|
||||
|
||||
create_table "pghero_query_stats", force: :cascade do |t|
|
||||
t.text "database"
|
||||
t.text "user"
|
||||
t.text "query"
|
||||
t.bigint "query_hash"
|
||||
t.float "total_time"
|
||||
t.bigint "calls"
|
||||
t.datetime "captured_at", precision: nil
|
||||
t.index ["database", "captured_at"], name: "index_pghero_query_stats_on_database_and_captured_at"
|
||||
end
|
||||
|
||||
create_table "raid_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name_en", null: false
|
||||
t.string "name_jp", null: false
|
||||
|
|
@ -373,6 +348,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
t.integer "element"
|
||||
t.string "slug"
|
||||
t.uuid "group_id"
|
||||
t.index ["group_id"], name: "index_raids_on_group_id"
|
||||
end
|
||||
|
||||
create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
|
@ -423,10 +399,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) 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
|
||||
|
||||
|
|
@ -471,6 +443,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) 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
|
||||
|
|
@ -500,12 +473,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) 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"
|
||||
end
|
||||
|
|
@ -521,6 +488,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) 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"
|
||||
|
|
@ -532,10 +500,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
|
|||
add_foreign_key "parties", "job_skills", column: "skill2_id"
|
||||
add_foreign_key "parties", "job_skills", column: "skill3_id"
|
||||
add_foreign_key "parties", "jobs"
|
||||
add_foreign_key "parties", "parties", column: "source_party_id"
|
||||
add_foreign_key "parties", "raids"
|
||||
add_foreign_key "parties", "users"
|
||||
add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey"
|
||||
add_foreign_key "raids", "raid_groups", column: "group_id"
|
||||
add_foreign_key "weapon_awakenings", "awakenings"
|
||||
add_foreign_key "weapon_awakenings", "weapons"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
# 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)
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
"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
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
"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/ニーア"
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
"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"
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
"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
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
"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
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
"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
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
"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/デス",,"{}","{}"
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
"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"
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
"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}"
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
"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,,,,,,,,,,,true,260,1300,1560,1550,8700,10250,,,,,,,,,,,,2024-09-24,,,SSRシャノワール,
|
||||
,,3040093000,,,,シャノワール (SSR),,,,,,,true,260,1300,1560,1550,8700,10250,,,,,,,,,,,,2024-09-24,,,SSRシャノワール,
|
||||
|
|
|
|||
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue