Compare commits

...

38 commits

Author SHA1 Message Date
b03d5e6618
Fix error in weapons (#199) 2025-03-30 21:08:04 -07:00
309a499446
Merge pull request #198 from jedmund/jedmund/202503-legfest
Adds items from the March 2025 Legend Festival/Event
2025-03-30 21:05:39 -07:00
832bf86d47 New items from March Legfest and Scenario Event 2025-03-31 00:02:25 -04:00
65a58d8b4c
Merge pull request #197 from jedmund/jedmund/20250327-new-items
Add new items from March 2025
2025-03-26 22:44:36 -07:00
92de40bbbf Add columns for Classic II and Collab Gacha 2025-03-27 01:37:53 -04:00
aaa046c01f Add new items 2025-03-27 01:37:42 -04:00
2f04a7d3a7
Add a data miner for downloading data from GBF (#196)
* First pass at dataminer service

* Got output printing from dataminer

* Fetches summons, characters and weapons

* Can loop over objects

* Finish dataminer

Adds logger and continuing downloads
2025-03-06 19:12:44 -08:00
7880ac76cc
Add data ingestion endpoints (#195)
* Update schema.rb

* Add endpoints for importing game data

This lets privileged users import canonical data for characters, weapons and summons directly from the game
2025-03-02 17:48:35 -08:00
28a6b1894e
Delete db/migrate/20250301143956_add_wiki_raw_to_characters.rb (#194) 2025-03-02 16:29:05 -08:00
3746ee9af6
Merge pull request #193 from jedmund/jedmund/wiki-fields
Implement columns for raw wiki/game data
2025-03-02 16:20:55 -08:00
cec6132823 Update wiki namespace in parsers 2025-03-02 16:19:13 -08:00
311c218863 Adds a task for fetching data from wiki
Can be used to fetch one object or multiple
2025-03-02 16:17:32 -08:00
c060a4525b Update migration
Now this adds raw data columns to weapons, summons, and characters
2025-03-02 16:08:35 -08:00
2b8dfe9e20 Merge branch 'main' into jedmund/wiki-fields 2025-03-01 07:32:17 -08:00
b1800f411f
Cleans up data on some Characters (#192)
This should update the `wiki_en` field for Kaguya, Fenie, and Uriel. It also adds `character_id` for 6 characters that were missing a value.
2025-03-01 07:31:49 -08:00
2de10d03f3 Fix path for Wiki object 2025-03-01 06:34:29 -08:00
a6ede6ecf7
Merge pull request #191 from jedmund/jedmund/parallelize-downloaders
Parallelize downloaders and update sizes
2025-03-01 05:46:08 -08:00
e75578bea3 Refactor download_all_images task
This refactor focuses on implementing parallelization. This allows us to pass in a number of threads and download concurrently. This makes downloading lots of images a lot faster.
2025-03-01 05:43:57 -08:00
ffbc8d0c1e Add support for passing in a Logger 2025-03-01 05:43:05 -08:00
0d997d6ad5 Add new image sizes
* Weapons can now download the “raw”image size, which is the weapon art without a background
* Characters now download the “detail” image size, which is a horizontal crop of the character’s art
* Summons now download the “detail” image size, which is a horizontal crop of the summon’s art
* Summons also download “ls” and “m” instead of “party_main” and “party_sub”, as they match the aspect ratio of weapon sizes better, which should make our lives a lot easier.
2025-03-01 05:42:40 -08:00
5955ef2650 Add support for single sizes
You can now pass one of the sizes in to only download that size for the object.
2025-03-01 05:40:34 -08:00
ae62d594a8 Add parallel gem 2025-03-01 05:29:21 -08:00
82b3d0ed88
Merge pull request #190 from jedmund/jedmund/add-recruits
Adds recruits value to new weapons
2025-03-01 05:25:15 -08:00
e1d983a6d4 Adds recruits IDs to new weapons 2025-02-28 18:24:15 -08:00
4d3c1a800b
Update config files (#189)
* Update weapon series migration

This update fixes MigrateWeaponSeries from 20250218 such that it can be run on an empty database without throwing errors.

* Update .gitignore

Hide backups and logs directories, since we’ll be storing these in the project folder. Also hide mise’s .local directory.

* Change NewRelic log directory

Moved from log/ to logs/

* Add rake task for backing up/restoring prod db

* Rubocop fixes

* Fix error where :preview_state didn’t have an attribute

* Add supervisord ini

This uses my local paths, so we should try to abstract that away later.

* Ignore mise.toml
2025-02-27 23:13:57 -08:00
b2d2952b35
Fix limit column in weapon migration (#188)
* Update weapon series migration

This update fixes MigrateWeaponSeries from 20250218 such that it can be run on an empty database without throwing errors.

* Add items from late 2025-02

* Yuel (Grand)
* Tsukuyomi
* Sennen Goji
* Nightgaze Gale
* Bane of Avidya
* Klesha-Cleaning Dharmachakra

* Add default value for limit column
2025-02-27 20:41:51 -08:00
6bcbc97566
Jedmund/202502 update (#187)
* Update weapon series migration

This update fixes MigrateWeaponSeries from 20250218 such that it can be run on an empty database without throwing errors.

* Add items from late 2025-02

* Yuel (Grand)
* Tsukuyomi
* Sennen Goji
* Nightgaze Gale
* Bane of Avidya
* Klesha-Cleaning Dharmachakra
2025-02-27 20:27:43 -08:00
9827658771
Render the correct view when updating jobs (#186)
We were referencing an old view in `PartiesController#update_job`. This has been corrected.
2025-02-25 09:11:13 -05:00
505176ae5f
Update api URL (#185)
This change updates the production API URL to https://api.granblue.team/v1 instead of https://api.granblue.team/api/v1.
2025-02-25 08:23:47 -05:00
6db5faeb98
Add items from January/February 2025 (#184)
* Add new items

Items from:
- Unite and Fight (January 2025)
- Story Event (January 2025)
- Negima Collab (February 2025)
- January Flash Gala
- January Legend Festival
- February Flash Gala
- February character uncap

Data fixes for
- Chat Noir FLB (rarity was set to a bad value)
- End of year weapons (FLB date not set)

* Fix CSVs

* Fix data update rake task

* Add CSV require in BaseImporter
2025-02-24 16:09:30 -05:00
11db6674fc
fix migrator (#183)
- rename migration so it happens first
- postdeployment database migrator now migrates database and data migrations in chronological order
2025-02-18 01:47:23 -08:00
bb96593798
Update database_migrator.rb (#182)
Make database migrator run migrations in order
2025-02-18 01:12:15 -08:00
3cdd925162
Fix filters and add processors (#181)
* Update test csvs

* Fix count filters and refactor apply_filters

* Update party_querying_concern.rb

* +tests/-debug logs

* Make party association optional in Job

* Updates for weapon series

- Change to new series numbers
- Add static method for querying whether the weapon's element is changeable
- Add a new method to return a text slug for the weapon's series

* Add and update test data

- Updates canonical.rb for loading multiple types of data with multiple types of associations
- Adds test data for Guidebooks, Job Accessories, Job Skills, and Jobs
- Updates test data for Weapons and Summons

* Migrations

- Adds series of migrations for changing the weapon's series to the values used by Cygames
- Shuffled around some foreign keys

* Implement BaseProcessor

Processors are in charge of processing deck data straight from Granblue.

* Implement CharacterProcessor

Process character data from deck

* Implement WeaponProcessor

Process weapon data from deck

* Implement JobProcessor

Process job, job skill, and job accessory data from deck

* Implement SummonProcessor

Process summon data from deck

* Update SummonProcessor to work like the others

* ImportController should use processors

* Process element for changeable weapons
2025-02-17 23:51:50 -08:00
60f153a169
Users#info endpoint uses username instead of ID (#180) 2025-02-13 02:43:16 -08:00
a042847aab
Migrate to Query Builder (#179)
* Moved queries into PartyQueryBuilder service

PartyQueryBuilder supersedes PartyQueryingConcern as it is also used for UsersController (and is our fix for profiles being broken)

* Implement PartyQueryBuilder in controllers

* Update summon_transformer.rb

This should fix the transformer so that we properly capture summons and subaura summons

* Update parties_controller_spec.rb

* Add NewRelic license key

* Add Sentry

Why not?
2025-02-12 23:43:02 -08:00
d6300f7aeb
Add first round of tests (#178)
* Install Rspec

* Create .aidigestignore

* Update rails_helper

- Added sections and comments
- Add support for loading via canonical.rb
- Add FactoryBot syntax methods
- Disable SQL logging in test environment

* Move gems around

* Add canonical.rb and test env CSVs

We load these CSVs via canonical.rb when we run tests as a data source for canonical objects.

* Remove RBS for now

This is too much and we need to find the right solution

* Refactor GridSummonsController and add tests

* Create GridSummon factory

* Refactor GridSummon and add documentation and tests

* Create have_error_on.rb

* Update .aidigestignore

* Fix warnings

* Add GridWeapons and Parties factories

* Refactor GridWeapon and add documentation and tests

* Create .rubocop.yml

* Create no_weapon_provided_error.rb

* Refactor GridWeaponsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Refactor GridSummonsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Enable shoulda/matchers

* Update User factory

* Update party.rb

We moved updating the party's element and extra flag to inside the party. We use an after_commit hook to minimize the amount of queries we're running to do this.

* Update party.rb

We change setting the edit key to use the conditional assignment operator so that it doesn't get overridden when we're running tests. This shouldn't have an effect in production.

* Update api_controller.rb

Change render_unprocessable_entity_response to render the errors hash instead of the exception so that we get more helpful errors.

* Add new errors

Added NoCharacterProvidedError and NoSummonProvidedError

* Add tests and docs to GridCharacter

We added a factory, spec and documentation to the GridCharacter model

* Ensure numericality

* Move enums into GranblueEnums

We don't use these yet, but it gives us a structured place to pull them from.

* Refactor GridCharactersController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Add debug hook and other small changes

* Update grid_characters_controller.rb

Removes logs

* Update .gitignore

* Update .aidigestignore

* Refactored PartiesController

- Split PartiesController into three concerns
- Implemented testing for PartiesController and two concerns
- Implemented fixes across other files to ensure PartiesController tests pass
- Added Favorites factory

* Implement SimpleCov

* Refactor Party model

- Refactors Party model
- Adds tests
- Adds documentation

* Update granblue_enums.rb

Remove included block
2025-02-12 02:42:30 -08:00
6cf11e6517
Jedmund/fix image embeds 4 (#177)
* Update gitignore

There is a mystery postgres folder and we are going to ignore it

* Add migrations

* Update preview state default to pending
* Adds indexes
* Adds PgHero and PgStatements

* Update .gitignore

* Update Gemfile

Production:
- `newrelic_rpm`

Development:
- `pg_query`
- `prosopite`

* Configure Sidekiq

Create job for cleaning up party previews

* Configure Prosopite and remove CacheFreeLogger

* Enable query logging

* Update api_controller.rb

Add N+1 detectioin via Prosopite in development/test environments

* Refactor canonical object blueprints

* Refactor grid object blueprints

* Remove N+1 from grid object models

Reimplementing `character` `summon` and `weapon` was making N+1s which made queries really slow

* Add counter caches to party

* Add preview generation helpers

The Party model can respond to queries about its preview state with the following models:
- `schedule_preview_generation`
- `preview_content_changed?`
- `preview_expired?`
- `should_generate_preview?`
- `ready_for_preview?`
- `needs_preview_generation?`
- `preview_relevant_attributes`

Removes the following methods:
- `schedule_preview_regeneration`
- `preview_relevant_changes?`

* Add cache to is_favorited(user)

* Refactored PartyBlueprint to minimize N+1s

* Remove preview expiry constants

These are defined in the Coordinator instead

* Add method comments

* Create parties_controller.rbs

* Update logic and logs

* Updates excluded methods and calculate_count

* Use `includes` instead of `joins`
* Use a less-insane way of counting

* Adds a helper method for party privacy

* Update filter condition helpers

Just minor refactoring

* Fix old view name in PartyBlueprint

* Refactor parties#create

* Remove redundant return

* Update parties_controller.rbs

* Update parties#index

* Update parties_controller.rb

Updates apply_includes and apply_excludes, along with modifying id_to_table and build_query

* Update parties_controller.rb

Adds the rest of the changes, too tired to write them all out. Some preview generation, some filtering

* Refactor parties#index and parties#favorites

These are mostly the same methods, so we remove common code into build_parties_query and render_paginated_parties

* Alias table name to object to maintain API consistency

* Maintain API consistency with raid blueprint

* Optimize party loading by adding eager loading to `set_from_slug`

- Refactored `set_from_slug` to use `includes` for eager loading associated models:
  - `user`, `job`, `raid` (with `group`)
  - `characters` (with `character` and `awakening`)
  - `weapons` (with `weapon`, `awakenings`, `weapon_key1`, `weapon_key2`, `weapon_key3`)
  - `summons` (with `summon`)
  - `guidebooks` (`guidebook1`, `guidebook2`, `guidebook3`)
  - `source_party`, `remixes`, `skills`, and `accessory`
- This change improves query efficiency by reducing N+1 queries and ensures all relevant associations are preloaded.
- Removed redundant favorite check as it was not necessary in this context.

* Refactor grid blueprints

- **GridCharacterBlueprint:**
  - Removed `:minimal` view restriction on `party` association.
  - Improved nil checks for `ring1`, `ring2`, and `earring` to prevent errors.
  - Converted string values in `awakening_level`, `over_mastery`, and `aetherial_mastery` fields to integers for consistency.
  - Ensured `over_mastery` and `aetherial_mastery` only include valid entries, filtering out blank or zero-modifier values.

- **GridWeaponBlueprint:**
  - Removed `:minimal` view restriction on `party` association.
  - Ensured `weapon` association exists before accessing `ax`, `series`, or `awakening`.
  - Improved conditional checks for `weapon_keys` to prevent errors when `weapon` or `series` is nil.
  - Converted `awakening_level` field to integer for consistency.

- **GridCharacterBlueprint:**
  - Removed `:minimal` view restriction on `party` association.

* Update raid blueprints

- Show flat representation of raid group in RaidBlueprint's nested view
- Show nested representation of raid in RaidGroupBlueprint's full view

* Move n+1 detection to around_action hook

* Improve handling mastery bonuses

- Improved handling of nested attributes:
  - Replaced old mastery structure with new `rings` and `awakening` assignments.
  - Added `new_rings` and `new_awakening` virtual attributes for easier updates.
  - Updated `assign_attributes` to exclude `rings` and `awakening` to prevent conflicts.

- Enhanced parameter transformation:
  - Introduced `transform_character_params` to process `rings`, `awakening`, and `earring` more reliably.
  - Ensured proper type conversion (`to_i`) for numeric values in `uncap_level`, `transcendence_step`, and `awakening_level`.
  - Improved error handling for missing values by setting defaults where needed.

- Optimized database queries:
  - Added `.includes(:awakening)` to `set` to prevent N+1 query issues.

- Updated strong parameters:
  - Changed `rings` from individual keys (`ring1`, `ring2`, etc.) to a structured array format.
  - Refactored permitted attributes to align with the new nested structure.

* Eager-load jobs when querying job skills

* Eager load raids/groups when querying

* Update users_controller.rb

More efficient way of denoting favorited parties.

* Update awakening.rb

- Removes explicitly defined associations and adds ActiveRecord associations instead

* Update party.rb

- Removes favorited accessor
- Renames derivative_parties to remixes and adds in-built sort

* Update weapon_awakening.rb

- Removes redefined explicit associations

* Update grid_character.rb

- Adds code transforming incoming ring and awakening values into something the db understands

* Update character.rb

Add explicit Awakenings enum

* Update coordinator.rb

Adds 'queued' as a state for generation
2025-02-09 22:50:18 -08:00
11d324efe9
Fix image embeds 3 (#176)
* Add default preview images

* Update application.rb

* Adds app assets path in API mode
* Cleans up file

* Create assets.rb

An initializer for font assets (for image generation)

* Updates to Canvas and Coordinator

* Update parties_controller.rb

* Adds retry header if generation is still in progress
* Streams S3 content instead of redirecting to prevent 302

* Update coordinator.rbs

* Create previews.rake

A rake task for generating images offline

* Add commands to build phase
2025-01-20 03:55:22 -08:00
173 changed files with 14843 additions and 2334 deletions

26
.aidigestignore Normal file
View file

@ -0,0 +1,26 @@
.*
app/assets
bin/
coverage/
download/
export/
log/
postgres/
public/
storage/
tmp/
vendor/
lib/
sig/
test/
LICENSE
logfile
config.ru
codebase.md
Rakefile
db/migrate/*
db/data/*
db/seed/updates/*

15
.gitignore vendored
View file

@ -7,6 +7,9 @@
# Ignore bundler config. # Ignore bundler config.
/.bundle /.bundle
# Ignore mystery Postgres folder
/postgres
# Ignore the default SQLite database. # Ignore the default SQLite database.
/db/*.sqlite3 /db/*.sqlite3
/db/*.sqlite3-journal /db/*.sqlite3-journal
@ -18,6 +21,9 @@
!/log/.keep !/log/.keep
!/tmp/.keep !/tmp/.keep
# Ignore simplecov directory
/coverage/*
# Ignore pidfiles, but keep the directory. # Ignore pidfiles, but keep the directory.
/tmp/pids/* /tmp/pids/*
!/tmp/pids/ !/tmp/pids/
@ -31,9 +37,12 @@
# Ignore master key for decrypting credentials and more. # Ignore master key for decrypting credentials and more.
/config/master.key /config/master.key
# Ignore exported and downloaded files # Ignore specific directories
/.local
/export /export
/download /download
/backups
/logs
.DS_Store .DS_Store
@ -46,3 +55,7 @@ config/application.yml
.vscode/* .vscode/*
/config/credentials/production.key /config/credentials/production.key
# Ignore AI Codebase-generated files
codebase.md
mise.toml

1
.rspec Normal file
View file

@ -0,0 +1 @@
--require spec_helper

2
.rubocop.yml Normal file
View file

@ -0,0 +1,2 @@
Layout/MultilineOperationIndentation:
EnforcedStyle: aligned

22
Gemfile
View file

@ -2,7 +2,6 @@ source 'https://rubygems.org'
ruby '3.3.7' ruby '3.3.7'
gem 'bootsnap' gem 'bootsnap'
gem 'pg'
gem 'rack-cors' gem 'rack-cors'
gem 'rails' gem 'rails'
gem 'sprockets-rails' gem 'sprockets-rails'
@ -10,6 +9,9 @@ gem 'sprockets-rails'
# A Ruby Web Server Built For Concurrency # A Ruby Web Server Built For Concurrency
gem 'puma' gem 'puma'
# Pg is the Ruby interface to the PostgreSQL RDBMS
gem 'pg'
# A sophisticated and secure hash algorithm for # A sophisticated and secure hash algorithm for
# hashing passwords. # hashing passwords.
gem 'bcrypt' gem 'bcrypt'
@ -71,6 +73,17 @@ gem 'httparty'
# StringScanner provides for lexical scanning operations on a String. # StringScanner provides for lexical scanning operations on a String.
gem 'strscan' 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 group :doc do
gem 'apipie-rails' gem 'apipie-rails'
gem 'sdoc' gem 'sdoc'
@ -79,8 +92,7 @@ end
group :development, :test do group :development, :test do
gem 'amazing_print' gem 'amazing_print'
gem 'dotenv-rails' gem 'dotenv-rails'
gem 'factory_bot_rails' gem 'prosopite'
gem 'faker'
gem 'pry' gem 'pry'
gem 'rspec_junit_formatter' gem 'rspec_junit_formatter'
gem 'rspec-rails' gem 'rspec-rails'
@ -88,6 +100,7 @@ end
group :development do group :development do
gem 'listen' gem 'listen'
gem 'pg_query'
gem 'solargraph' gem 'solargraph'
gem 'spring' gem 'spring'
gem 'spring-commands-rspec' gem 'spring-commands-rspec'
@ -102,6 +115,9 @@ group :test do
gem 'api_matchers' gem 'api_matchers'
gem 'byebug' gem 'byebug'
gem 'database_cleaner' gem 'database_cleaner'
gem 'factory_bot_rails'
gem 'faker'
gem 'rspec'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', require: false gem 'simplecov', require: false
end end

View file

@ -86,8 +86,8 @@ GEM
awesome_nested_set (3.8.0) awesome_nested_set (3.8.0)
activerecord (>= 4.0.0, < 8.1) activerecord (>= 4.0.0, < 8.1)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.1038.0) aws-partitions (1.1044.0)
aws-sdk-core (3.216.0) aws-sdk-core (3.217.1)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -95,7 +95,7 @@ GEM
aws-sdk-kms (1.97.0) aws-sdk-kms (1.97.0)
aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.178.0) aws-sdk-s3 (1.179.0)
aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
@ -135,14 +135,13 @@ GEM
dotenv (= 3.1.7) dotenv (= 3.1.7)
railties (>= 6.1) railties (>= 6.1)
drb (2.2.1) drb (2.2.1)
e2mmap (0.1.0)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
factory_bot (6.5.0) factory_bot (6.5.1)
activesupport (>= 5.0.0) activesupport (>= 6.1.0)
factory_bot_rails (6.4.4) factory_bot_rails (6.4.4)
factory_bot (~> 6.5) factory_bot (~> 6.5)
railties (>= 5.0.0) railties (>= 5.0.0)
@ -166,14 +165,30 @@ GEM
gemoji (>= 2.1.0) gemoji (>= 2.1.0)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.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) httparty (0.22.0)
csv csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
i18n (1.14.6) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.0) io-console (0.8.0)
irb (1.14.3) irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jaro_winkler (1.6.0) jaro_winkler (1.6.0)
@ -183,7 +198,7 @@ GEM
rexml (>= 3.3.9) rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.4)
listen (3.9.0) listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
@ -213,46 +228,54 @@ GEM
timeout timeout
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
newrelic_rpm (9.17.0)
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.1-aarch64-linux-gnu) nokogiri (1.18.2-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-aarch64-linux-musl) nokogiri (1.18.2-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-arm-linux-gnu) nokogiri (1.18.2-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-arm-linux-musl) nokogiri (1.18.2-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-arm64-darwin) nokogiri (1.18.2-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-x86_64-darwin) nokogiri (1.18.2-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-x86_64-linux-gnu) nokogiri (1.18.2-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.1-x86_64-linux-musl) nokogiri (1.18.2-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
observer (0.1.2)
oj (3.16.9) oj (3.16.9)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
ostruct (0.6.1) ostruct (0.6.1)
parallel (1.26.3) parallel (1.26.3)
parser (3.3.6.0) parser (3.3.7.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.5.9) pg (1.5.9)
pg_query (6.0.0)
google-protobuf (>= 3.25.3)
pg_search (2.3.7) pg_search (2.3.7)
activerecord (>= 6.1) activerecord (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
prosopite (1.4.2)
pry (0.15.2) pry (0.15.2)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
psych (5.2.2) psych (5.2.3)
date date
stringio stringio
puma (6.5.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.8) rack (3.1.9)
rack-cors (2.0.2) rack-cors (2.0.2)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-session (2.1.0) rack-session (2.1.0)
@ -296,8 +319,9 @@ GEM
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.11.1) rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
rbs (2.8.4) rbs (3.8.1)
rdoc (6.10.0) logger
rdoc (6.11.0)
psych (>= 4.0.0) psych (>= 4.0.0)
redis (5.3.0) redis (5.3.0)
redis-client (>= 0.22.0) redis-client (>= 0.22.0)
@ -309,7 +333,7 @@ GEM
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
reverse_markdown (2.1.1) reverse_markdown (3.0.0)
nokogiri nokogiri
rexml (3.4.0) rexml (3.4.0)
rspec (3.13.0) rspec (3.13.0)
@ -335,17 +359,17 @@ GEM
rspec-support (3.13.2) rspec-support (3.13.2)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.70.0) rubocop (1.71.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0) rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0) rubocop-ast (1.38.0)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rufus-scheduler (3.9.2) rufus-scheduler (3.9.2)
@ -353,9 +377,16 @@ GEM
sdoc (2.6.1) sdoc (2.6.1)
rdoc (>= 5.0) rdoc (>= 5.0)
securerandom (0.4.1) 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) shoulda-matchers (6.4.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (7.3.7) sidekiq (7.3.8)
base64
connection_pool (>= 2.3.0) connection_pool (>= 2.3.0)
logger logger
rack (>= 2.2.4) rack (>= 2.2.4)
@ -366,18 +397,20 @@ GEM
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1) simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
solargraph (0.50.0) solargraph (0.51.1)
backport (~> 1.2) backport (~> 1.2)
benchmark benchmark
bundler (~> 2.0) bundler (~> 2.0)
diff-lcs (~> 1.4) diff-lcs (~> 1.4)
e2mmap jaro_winkler (~> 1.6)
jaro_winkler (~> 1.5)
kramdown (~> 2.3) kramdown (~> 2.3)
kramdown-parser-gfm (~> 1.1) kramdown-parser-gfm (~> 1.1)
logger (~> 1.6)
observer (~> 0.1)
ostruct (~> 0.6)
parser (~> 3.0) parser (~> 3.0)
rbs (~> 2.0) rbs (~> 3.0)
reverse_markdown (~> 2.0) reverse_markdown (>= 2.0, < 4)
rubocop (~> 1.38) rubocop (~> 1.38)
thor (~> 1.0) thor (~> 1.0)
tilt (~> 2.0) tilt (~> 2.0)
@ -393,6 +426,7 @@ GEM
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.8.0) squasher (0.8.0)
stackprof (0.2.27)
stringio (3.1.2) stringio (3.1.2)
strscan (3.1.2) strscan (3.1.2)
thor (1.3.2) thor (1.3.2)
@ -446,20 +480,27 @@ DEPENDENCIES
httparty httparty
listen listen
mini_magick mini_magick
newrelic_rpm
oj oj
parallel
pg pg
pg_query
pg_search pg_search
prosopite
pry pry
puma puma
rack-cors rack-cors
rails rails
redis redis
responders responders
rspec
rspec-rails rspec-rails
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rufus-scheduler rufus-scheduler
sdoc sdoc
sentry-rails
sentry-ruby
shoulda-matchers shoulda-matchers
sidekiq sidekiq
simplecov simplecov
@ -468,6 +509,7 @@ DEPENDENCIES
spring-commands-rspec spring-commands-rspec
sprockets-rails sprockets-rails
squasher (>= 0.6.0) squasher (>= 0.6.0)
stackprof
strscan strscan
will_paginate (~> 3.3) will_paginate (~> 3.3)

View file

@ -3,72 +3,77 @@
module Api module Api
module V1 module V1
class CharacterBlueprint < ApiBlueprint class CharacterBlueprint < ApiBlueprint
field :name do |w| field :name do |c|
{ {
en: w.name_en, en: c.name_en,
ja: w.name_jp ja: c.name_jp
} }
end end
fields :granblue_id, :character_id, :rarity, fields :granblue_id, :character_id, :rarity,
:element, :gender, :special :element, :gender, :special
field :uncap do |w| field :uncap do |c|
{ {
flb: w.flb, flb: c.flb,
ulb: w.ulb ulb: c.ulb
} }
end end
field :hp do |w| field :race do |c|
{ [c.race1, c.race2].compact
min_hp: w.min_hp,
max_hp: w.max_hp,
max_hp_flb: w.max_hp_flb
}
end end
field :atk do |w| field :proficiency do |c|
{ [c.proficiency1, c.proficiency2].compact
min_atk: w.min_atk,
max_atk: w.max_atk,
max_atk_flb: w.max_atk_flb
}
end end
field :race do |w| view :full do
[ include_view :stats
w.race1, include_view :rates
w.race2 include_view :dates
]
end
field :proficiency do |w| field :awakenings do
[ Character::AWAKENINGS.map do |awakening|
w.proficiency1, AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
w.proficiency2 end
]
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
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 end
end end

View file

@ -3,57 +3,64 @@
module Api module Api
module V1 module V1
class GridCharacterBlueprint < ApiBlueprint class GridCharacterBlueprint < ApiBlueprint
view :uncap do fields :position, :uncap_level, :perpetuity
association :party, blueprint: PartyBlueprint, view: :minimal
fields :position, :uncap_level field :transcendence_step, if: ->(_field, gc, _options) { gc.character&.ulb } do |gc|
gc.transcendence_step
end end
view :nested do view :preview do
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 association :character, name: :object, blueprint: CharacterBlueprint
end end
view :full do view :nested do
include_view :nested include_view :mastery_bonuses
association :party, blueprint: PartyBlueprint, view: :minimal association :character, name: :object, blueprint: CharacterBlueprint, view: :full
end
view :uncap do
association :party, blueprint: PartyBlueprint
fields :position, :uncap_level
end end
view :destroyed do view :destroyed do
fields :position, :created_at, :updated_at fields :position, :created_at, :updated_at
end 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 end
end end

View file

@ -3,19 +3,24 @@
module Api module Api
module V1 module V1
class GridSummonBlueprint < ApiBlueprint class GridSummonBlueprint < ApiBlueprint
view :uncap do fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
association :party, blueprint: PartyBlueprint, view: :minimal
fields :position, :uncap_level, :transcendence_step view :preview do
association :summon, name: :object, blueprint: SummonBlueprint
end end
view :nested do view :nested do
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step association :summon, name: :object, blueprint: SummonBlueprint, view: :full
association :summon, name: :object, blueprint: SummonBlueprint
end end
view :full do view :full do
include_view :nested include_view :nested
association :party, blueprint: PartyBlueprint, view: :minimal association :party, blueprint: PartyBlueprint
end
view :uncap do
association :party, blueprint: PartyBlueprint
fields :position, :uncap_level, :transcendence_step
end end
view :destroyed do view :destroyed do

View file

@ -3,45 +3,47 @@
module Api module Api
module V1 module V1
class GridWeaponBlueprint < ApiBlueprint class GridWeaponBlueprint < ApiBlueprint
view :uncap do fields :mainhand, :position, :uncap_level, :transcendence_step, :element
association :party, blueprint: PartyBlueprint, view: :minimal
fields :position, :uncap_level view :preview do
association :weapon, name: :object, blueprint: WeaponBlueprint
end end
view :nested do view :nested do
fields :mainhand, :position, :uncap_level, :transcendence_step, :element field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w|
association :weapon, name: :object, blueprint: WeaponBlueprint [
{ 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? }
association :weapon_keys, association :weapon_keys,
blueprint: WeaponKeyBlueprint, blueprint: WeaponKeyBlueprint,
if: lambda { |_field_name, w, _options| if: ->(_field_name, w, _options) {
[2, 3, 17, 24, 34].include?(w.weapon.series) w.weapon.present? &&
w.weapon.series.present? &&
[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 end
view :full do view :full do
include_view :nested include_view :nested
association :party, blueprint: PartyBlueprint, view: :minimal association :party, blueprint: PartyBlueprint
end
view :uncap do
association :party, blueprint: PartyBlueprint
fields :position, :uncap_level
end end
view :destroyed do view :destroyed do

View file

@ -3,105 +3,141 @@
module Api module Api
module V1 module V1
class PartyBlueprint < ApiBlueprint class PartyBlueprint < ApiBlueprint
view :weapons do # 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])
end
# For collection views
view :preview do
include_view :preview_objects # Characters, Weapons, Summons
include_view :preview_metadata # Object counts
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
# 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, association :weapons,
blueprint: GridWeaponBlueprint, blueprint: GridWeaponBlueprint,
view: :nested view: :nested
end
view :summons do
association :summons, association :summons,
blueprint: GridSummonBlueprint, blueprint: GridSummonBlueprint,
view: :nested view: :nested
end end
view :characters do # Metadata views
association :characters, view :preview_metadata do
blueprint: GridCharacterBlueprint, field :counts do |party|
view: :nested
end
view :job_skills do
field :job_skills do |job|
{ {
'0' => !job.skill0.nil? ? JobSkillBlueprint.render_as_hash(job.skill0) : nil, weapons: party.weapons_count,
'1' => !job.skill1.nil? ? JobSkillBlueprint.render_as_hash(job.skill1) : nil, characters: party.characters_count,
'2' => !job.skill2.nil? ? JobSkillBlueprint.render_as_hash(job.skill2) : nil, summons: party.summons_count
'3' => !job.skill3.nil? ? JobSkillBlueprint.render_as_hash(job.skill3) : nil
} }
end end
end end
view :minimal do view :source_party 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
view :preview do
include_view :minimal
include_view :characters
include_view :weapons
include_view :summons
end
view :full do
include_view :preview
include_view :summons
include_view :characters
include_view :job_skills
fields :local_id, :description, :charge_attack,
:button_count, :turn_count, :chain_count,
:master_level, :ultimate_mastery
association :accessory,
blueprint: JobAccessoryBlueprint
association :source_party, association :source_party,
blueprint: PartyBlueprint, blueprint: PartyBlueprint,
view: :minimal view: :preview,
if: ->(_field_name, party, _options) { party.source_party_id.present? }
end
# TODO: This should probably be paginated view :remix_metadata do
include_view :source_party
# Re-added remixes association
association :remixes, association :remixes,
blueprint: PartyBlueprint, blueprint: PartyBlueprint,
view: :collection view: :preview
end end
view :collection do # Job-related views
include_view :preview 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
association :accessory,
blueprint: JobAccessoryBlueprint,
if: ->(_field_name, party, _options) { party.accessory_id.present? }
end end
# Created view
view :created do view :created do
include_view :full include_view :full
fields :edit_key fields :edit_key
end end
# Remixed view
view :remixed do
include_view :created
include_view :source_party
end
# Destroyed view
view :destroyed do view :destroyed do
fields :name, :description, :created_at, :updated_at fields :name, :description, :created_at, :updated_at
end end

View file

@ -12,6 +12,8 @@ module Api
end end
fields :slug, :level, :element fields :slug, :level, :element
association :group, blueprint: RaidGroupBlueprint, view: :flat
end end
view :full do view :full do

View file

@ -16,7 +16,7 @@ module Api
view :full do view :full do
include_view :flat include_view :flat
association :raids, blueprint: RaidBlueprint, view: :full association :raids, blueprint: RaidBlueprint, view: :nested
end end
end end
end end

View file

@ -3,41 +3,55 @@
module Api module Api
module V1 module V1
class SummonBlueprint < ApiBlueprint class SummonBlueprint < ApiBlueprint
field :name do |w| field :name do |s|
{ {
en: w.name_en, en: s.name_en,
ja: w.name_jp ja: s.name_jp
} }
end end
fields :granblue_id, :element, :rarity, :max_level fields :granblue_id, :element, :rarity, :max_level
field :uncap do |w| field :uncap do |s|
{ {
flb: w.flb, flb: s.flb,
ulb: w.ulb, ulb: s.ulb,
transcendence: w.transcendence transcendence: s.transcendence
} }
end end
field :hp do |w| view :stats do
{ field :hp do |s|
min_hp: w.min_hp, {
max_hp: w.max_hp, min_hp: s.min_hp,
max_hp_flb: w.max_hp_flb, max_hp: s.max_hp,
max_hp_ulb: w.max_hp_ulb, max_hp_flb: s.max_hp_flb,
max_hp_xlb: w.max_hp_xlb 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
end end
field :atk do |w| view :dates do
{ field :release_date
min_atk: w.min_atk, field :flb_date
max_atk: w.max_atk, field :ulb_date
max_atk_flb: w.max_atk_flb, field :transcendence_date
max_atk_ulb: w.max_atk_ulb, end
max_atk_xlb: w.max_atk_xlb
} view :full do
include_view :stats
include_view :dates
end end
end end
end end

View file

@ -10,10 +10,12 @@ module Api
} }
end end
# Primary information
fields :granblue_id, :element, :proficiency, fields :granblue_id, :element, :proficiency,
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity, :max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
:series, :ax, :ax_type :series, :ax, :ax_type
# Uncap information
field :uncap do |w| field :uncap do |w|
{ {
flb: w.flb, flb: w.flb,
@ -22,28 +24,39 @@ module Api
} }
end end
field :hp do |w| view :stats do
{ field :hp do |w|
min_hp: w.min_hp, {
max_hp: w.max_hp, min_hp: w.min_hp,
max_hp_flb: w.max_hp_flb, max_hp: w.max_hp,
max_hp_ulb: w.max_hp_ulb 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 :awakenings, if: ->(_fn, obj, _opt) { obj.awakenings.length.positive? } do |w|
w.awakenings.map do |a|
AwakeningBlueprint.render_as_hash(a)
end 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
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
association :awakenings,
blueprint: AwakeningBlueprint,
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
end end
end end
end end

View file

@ -31,6 +31,7 @@ module Api
##### Hooks ##### Hooks
before_action :current_user before_action :current_user
before_action :default_content_type before_action :default_content_type
around_action :n_plus_one_detection, unless: -> { Rails.env.production? }
##### Responders ##### Responders
respond_to :json respond_to :json
@ -85,7 +86,7 @@ module Api
end end
def render_unprocessable_entity_response(exception) def render_unprocessable_entity_response(exception)
render json: ErrorBlueprint.render_as_json(nil, exception: exception), render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
status: :unprocessable_entity status: :unprocessable_entity
end end
@ -104,9 +105,9 @@ module Api
def render_not_found_response(object) def render_not_found_response(object)
render json: ErrorBlueprint.render(nil, error: { render json: ErrorBlueprint.render(nil, error: {
message: "#{object.capitalize} could not be found", message: "#{object.capitalize} could not be found",
code: 'not_found' code: 'not_found'
}), status: :not_found }), status: :not_found
end end
def render_unauthorized_response def render_unauthorized_response
@ -119,6 +120,13 @@ module Api
def restrict_access def restrict_access
raise UnauthorizedError unless current_user raise UnauthorizedError unless current_user
end end
def n_plus_one_detection
Prosopite.scan
yield
ensure
Prosopite.finish
end
end end
end end
end end

View file

@ -2,68 +2,115 @@
module Api module Api
module V1 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 class GridCharactersController < Api::V1::ApiController
attr_reader :party, :incoming_character, :current_characters before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve]
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
before_action :find_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 :find_incoming_character, only: :create
before_action :find_current_characters, only: :create before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
##
# Creates a new grid character.
#
# If a conflicting grid character is found (i.e. one with the same character_id already exists
# in the party), a conflict view is rendered so the user can decide on removal. Otherwise,
# any grid character occupying the desired position is removed and a new one is created.
#
# @return [void]
def create def create
if !conflict_characters.nil? && conflict_characters.length.positive? processed_params = transform_character_params(character_params)
# Render a template with the conflicting and incoming characters,
# as well as the selected position, so the user can be presented with
# a decision.
# Up to 3 characters can be removed at the same time if conflict_characters.present?
conflict_view = render_conflict_view(conflict_characters, incoming_character, character_params[:position]) render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position])
render json: conflict_view
else else
# Destroy the grid character in the position if it is already filled # Remove any existing grid character occupying the specified position.
if GridCharacter.where(party_id: party.id, position: character_params[:position]).exists? if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position]))
character = GridCharacter.where(party_id: party.id, position: character_params[:position]).limit(1)[0] existing.destroy
character.destroy
end end
# Then, create a new grid character # Build the new grid character
character = GridCharacter.create!(character_params.merge(party_id: party.id, grid_character = build_new_grid_character(processed_params)
character_id: incoming_character.id))
if character.save! if grid_character.save
grid_character_view = render_grid_character_view(character) render json: GridCharacterBlueprint.render(grid_character,
render json: grid_character_view, status: :created root: :grid_character,
view: :nested), status: :created
else
render_validation_error_response(grid_character)
end end
end 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 def update
mastery = {} processed_params = transform_character_params(character_params)
%i[ring1 ring2 ring3 ring4 earring awakening].each do |key| assign_raw_attributes(@grid_character)
value = character_params.to_h[key] assign_transformed_attributes(@grid_character, processed_params)
mastery[key] = value unless value.nil?
if @grid_character.save
render json: GridCharacterBlueprint.render(@grid_character,
root: :grid_character,
view: :nested)
else
render_validation_error_response(@grid_character)
end end
@character.attributes = character_params.merge(mastery)
return render json: GridCharacterBlueprint.render(@character, view: :full) if @character.save
render_validation_error_response(@character)
end end
##
# Updates the uncap level and transcendence step of a grid character.
#
# The grid character's uncap level and transcendence step are updated based on the provided parameters.
# This action requires that the current user is authorized to modify the party.
#
# @return [void]
def update_uncap_level
@grid_character.uncap_level = character_params[:uncap_level]
@grid_character.transcendence_step = character_params[:transcendence_step]
if @grid_character.save
render json: GridCharacterBlueprint.render(@grid_character,
root: :grid_character,
view: :nested)
else
render_validation_error_response(@grid_character)
end
end
##
# Resolves conflicts for grid characters.
#
# This action destroys any conflicting grid characters as well as any grid character occupying
# the target position, then creates a new grid character using a computed default uncap level.
# The default uncap level is determined by the incoming character's attributes.
#
# @return [void]
def resolve def resolve
incoming = Character.find(resolve_params[:incoming]) incoming = Character.find_by(id: resolve_params[:incoming])
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) } render_not_found_response('character') and return unless incoming
party = conflicting.first.party
# Destroy each conflicting character conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
conflicting.each { |character| GridCharacter.destroy(character.id) } conflicting.each(&:destroy)
# Destroy the character at the desired position if it exists if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position]))
existing_character = GridCharacter.where(party: party.id, position: resolve_params[:position]).first existing.destroy
GridCharacter.destroy(existing_character.id) if existing_character end
# Compute the default uncap level based on the incoming character's flags.
if incoming.special if incoming.special
uncap_level = 3 uncap_level = 3
uncap_level = 5 if incoming.ulb uncap_level = 5 if incoming.ulb
@ -74,33 +121,146 @@ module Api
uncap_level = 5 if incoming.flb uncap_level = 5 if incoming.flb
end end
character = GridCharacter.create!(party_id: party.id, character_id: incoming.id, grid_character = GridCharacter.create!(
position: resolve_params[:position], uncap_level: uncap_level) party_id: @party.id,
render json: GridCharacterBlueprint.render(character, view: :nested), status: :created if character.save! character_id: incoming.id,
position: resolve_params[:position],
uncap_level: uncap_level
)
render json: GridCharacterBlueprint.render(grid_character,
root: :grid_character,
view: :nested), status: :created
end end
def update_uncap_level ##
character = GridCharacter.find(character_params[:id]) # Destroys a grid character.
#
render_unauthorized_response if current_user && (character.party.user != current_user) # 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.
character.uncap_level = character_params[:uncap_level] #
character.transcendence_step = character_params[:transcendence_step] # @return [void]
return unless character.save!
render json: GridCharacterBlueprint.render(character, view: :nested, root: :grid_character)
end
# TODO: Implement removing characters
def destroy def destroy
render_unauthorized_response if @character.party.user != current_user grid_character = GridCharacter.find_by('id = ?', params[:id])
return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @character.destroy
return render_not_found_response('grid_character') if grid_character.nil?
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy
end end
private 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 def conflict_characters
@conflict_characters ||= find_conflict_characters(incoming_character) @party.characters.where(character_id: @incoming_character.id).to_a
end end
def find_conflict_characters(incoming_character) def find_conflict_characters(incoming_character)
@ -122,52 +282,124 @@ module Api
end.flatten end.flatten
end end
def set ##
@character = GridCharacter.find(params[:id]) # Finds and sets the party based on parameters.
end #
# Checks for the party id in params[:character][:party_id], params[:party_id], or falls back to the party
def find_incoming_character # associated with the current grid character. Renders a not found response if the party is missing.
@incoming_character = Character.find(character_params[:character_id]) #
end # @return [void]
def find_party def find_party
@party = Party.find(character_params[:party_id]) @party = Party.find_by(id: params.dig(:character, :party_id)) ||
render_unauthorized_response if current_user && (party.user != current_user) Party.find_by(id: params[:party_id]) ||
@grid_character&.party
render_not_found_response('party') unless @party
end end
def authorize ##
# Create # Finds and sets the grid character based on the provided parameters.
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) # Searches for a grid character by its ID and renders a not found response if it is absent.
#
render_unauthorized_response if unauthorized_create || unauthorized_update # @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 end
# Specify whitelisted properties that can be modified. ##
# Finds and sets the incoming character based on the provided parameters.
#
# Searches for a character using the :character_id parameter and renders a not found response if it is absent.
#
# @return [void]
def find_incoming_character
@incoming_character = Character.find_by(id: character_params[:character_id])
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character
end
##
# 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
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
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.
def character_params def character_params
params.require(:character).permit(:id, :party_id, :character_id, :position, params.require(:character).permit(
:uncap_level, :transcendence_step, :perpetuity, :id,
:awakening_id, :awakening_level, :party_id,
ring1: %i[modifier strength], ring2: %i[modifier strength], :character_id,
ring3: %i[modifier strength], ring4: %i[modifier strength], :position,
earring: %i[modifier strength]) :uncap_level,
:transcendence_step,
:perpetuity,
awakening: %i[id level],
rings: %i[modifier strength],
earring: %i[modifier strength]
)
end end
##
# Specifies and permits the allowed resolve parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def resolve_params def resolve_params
params.require(:resolve).permit(:position, :incoming, conflicting: []) params.require(:resolve).permit(:position, :incoming, conflicting: [])
end 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 end
end end

View file

@ -2,82 +2,142 @@
module Api module Api
module V1 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 class GridSummonsController < Api::V1::ApiController
attr_reader :party, :incoming_summon attr_reader :party, :incoming_summon
before_action :set, only: %w[update update_uncap_level update_quick_summon] before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy]
before_action :find_party, only: :create before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy]
before_action :find_incoming_summon, only: :create before_action :find_incoming_summon, only: :create
before_action :authorize, only: %i[create update update_uncap_level update_quick_summon destroy] before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
##
# Creates a new grid summon.
#
# This method builds a new grid summon using the permitted parameters merged
# with the party and summon IDs. It ensures that the `uncap_level` is set to the
# maximum allowed level if not provided. Depending on validation, it will either save
# the summon, handle conflict resolution, or render a validation error response.
#
# @return [void]
def create def create
# Create the GridSummon with the desired parameters # Build a new grid summon using permitted parameters merged with party and summon IDs.
summon = GridSummon.new # Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
summon.attributes = summon_params.merge(party_id: party.id, summon_id: incoming_summon.id) # if it hasn't already been provided.
summon.uncap_level = max_uncap_level(summon) if summon.uncap_level.nil? grid_summon = build_grid_summon.tap do |gs|
gs.uncap_level ||= max_uncap_level(gs)
end
if summon.validate # If the grid summon is valid (i.e. it passes all validations), then save it normally.
save_summon(summon) if grid_summon.valid?
save_summon(grid_summon)
# If it is invalid due to a conflict error, handle the conflict resolution flow.
elsif conflict_error?(grid_summon)
handle_conflict(grid_summon)
# If there's some other kind of validation error, render the validation error response back to the client.
else else
handle_conflict(summon) render_validation_error_response(grid_summon)
end end
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 def update
@summon.attributes = summon_params @grid_summon.attributes = summon_params
return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save
render_validation_error_response(@character) render_validation_error_response(@grid_summon)
end 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 def update_uncap_level
summon = @summon.summon summon = @grid_summon.summon
max_uncap_level = max_uncap_level(summon) max_level = max_uncap_level(summon)
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_uncap_level greater_than_max_uncap = summon_params[:uncap_level].to_i > max_level
can_be_transcended = summon.transcendence && summon_params[:transcendence_step] && summon_params[:transcendence_step]&.to_i&.positive? can_be_transcended = summon.transcendence &&
summon_params[:transcendence_step].present? &&
summon_params[:transcendence_step].to_i.positive?
uncap_level = if greater_than_max_uncap || can_be_transcended new_uncap_level = greater_than_max_uncap || can_be_transcended ? max_level : summon_params[:uncap_level]
max_uncap_level new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
else
summon_params[:uncap_level]
end
transcendence_step = if summon.transcendence && summon_params[:transcendence_step] if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
summon_params[:transcendence_step] render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon)
else else
0 render_validation_error_response(@grid_summon)
end 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 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 def update_quick_summon
return if [4, 5, 6].include?(@summon.position) return if [4, 5, 6].include?(@grid_summon.position)
quick_summons = @summon.party.summons.select(&:quick_summon) quick_summons = @grid_summon.party.summons.select(&:quick_summon)
quick_summons.each do |summon| quick_summons.each do |summon|
summon.update!(quick_summon: false) summon.update!(quick_summon: false)
end end
@summon.update!(quick_summon: summon_params[:quick_summon]) @grid_summon.update!(quick_summon: summon_params[:quick_summon])
return unless @summon.persisted? return unless @grid_summon.persisted?
quick_summons -= [@summon] quick_summons -= [@grid_summon]
summons = [@summon] + quick_summons summons = [@grid_summon] + quick_summons
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons) render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
end 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) def save_summon(summon)
if (grid_summon = GridSummon.where( if (grid_summon = GridSummon.where(
party_id: party.id, party_id: party.id,
@ -92,6 +152,15 @@ module Api
render json: output, status: :created render json: output, status: :created
end 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 summons 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) def handle_conflict(summon)
conflict_summon = summon.conflicts(party) conflict_summon = summon.conflicts(party)
return unless conflict_summon.summon.id == incoming_summon.id return unless conflict_summon.summon.id == incoming_summon.id
@ -105,14 +174,96 @@ module Api
render json: output render json: output
end 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 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) def max_uncap_level(summon)
if summon.flb && !summon.ulb && !summon.transcendence if summon.flb && !summon.ulb && !summon.transcendence
4 4
@ -125,35 +276,65 @@ module Api
end end
end end
def find_incoming_summon ##
@incoming_summon = Summon.find_by(id: summon_params[:summon_id]) # Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
#
# For parties associated with a user, it verifies that the current_user is the owner.
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
#
# @return [void]
def authorize_party_edit!
if @party.user.present?
authorize_user_party
else
authorize_anonymous_party
end
end end
def find_party ##
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party # Authorizes an action for a party that belongs to a user.
@party = Party.find(summon_params[:party_id]) #
render_unauthorized_response if current_user && (party.user != current_user) # Renders an unauthorized response unless the current user is present and
# matches the party's user.
#
# @return [void]
def authorize_user_party
return if current_user.present? && @party.user == current_user
render_unauthorized_response
end end
def render_grid_summon_view(grid_summon, conflict_position = nil) ##
GridSummonBlueprint.render(grid_summon, view: :nested, # Authorizes an action for an anonymous party using an edit key.
root: :grid_summon, #
meta: { replaced: conflict_position }) # Retrieves and normalizes the provided edit key and compares it with the party's edit key.
# Renders an unauthorized response unless the keys are valid.
#
# @return [void]
def authorize_anonymous_party
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
return if valid_edit_key?(provided_edit_key, party_edit_key)
render_unauthorized_response
end end
def authorize ##
# Create # Validates that the provided edit key matches the party's edit key.
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) # @param provided_edit_key [String] the edit key provided in the request.
# @param party_edit_key [String] the edit key associated with the party.
render_unauthorized_response if unauthorized_create || unauthorized_update # @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 end
def set ##
@summon = GridSummon.find_by('id = ?', summon_params[:id]) # Defines and permits the whitelisted parameters for a grid summon.
end #
# @return [ActionController::Parameters] The permitted parameters.
# Specify whitelisted properties that can be modified.
def summon_params def summon_params
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend, params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend,
:quick_summon, :uncap_level, :transcendence_step) :quick_summon, :uncap_level, :transcendence_step)

View file

@ -2,110 +2,143 @@
module Api module Api
module V1 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 class GridWeaponsController < Api::V1::ApiController
attr_reader :party, :incoming_weapon 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 :set, except: %w[create update_uncap_level] before_action :find_incoming_weapon, only: %i[create resolve]
before_action :find_party, only: :create before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
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 def create
# Create the GridWeapon with the desired parameters return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
weapon = GridWeapon.new
weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id)
if weapon.validate grid_weapon = GridWeapon.new(
save_weapon(weapon) weapon_params.merge(
party_id: @party.id,
weapon_id: @incoming_weapon.id
)
)
if grid_weapon.valid?
save_weapon(grid_weapon)
else else
handle_conflict(weapon) if grid_weapon.errors[:series].include?('must not conflict with existing weapons')
handle_conflict(grid_weapon)
else
render_validation_error_response(grid_weapon)
end
end end
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
# weapons 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 def resolve
incoming = Weapon.find(resolve_params[:incoming]) incoming = Weapon.find_by(id: resolve_params[:incoming])
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) } conflicting_ids = resolve_params[:conflicting]
party = conflicting.first.party conflicting_weapons = GridWeapon.where(id: conflicting_ids)
# Destroy each conflicting weapon # Destroy each conflicting weapon
conflicting.each { |weapon| GridWeapon.destroy(weapon.id) } conflicting_weapons.each(&:destroy)
# Destroy the weapon at the desired position if it exists # Destroy the weapon at the desired position if it exists
existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
GridWeapon.destroy(existing_weapon.id) if existing_weapon existing_weapon.destroy
end
uncap_level = 3 # Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
uncap_level = 4 if incoming.flb new_uncap = compute_default_uncap(incoming)
uncap_level = 5 if incoming.ulb grid_weapon = GridWeapon.create!(
party_id: @party.id,
weapon = GridWeapon.create!(party_id: party.id, weapon_id: incoming.id, weapon_id: incoming.id,
position: resolve_params[:position], uncap_level: uncap_level) position: resolve_params[:position],
uncap_level: new_uncap,
return unless weapon.save transcendence_step: 0
view = render_grid_weapon_view(weapon, resolve_params[:position])
render json: view, status: :created
end
def update
render_unauthorized_response if current_user && (@weapon.party.user != current_user)
# TODO: Server-side validation of weapon mods
# We don't want someone modifying the JSON and adding
# keys to weapons that cannot have them
# Maybe we make methods on the model to validate for us somehow
@weapon.assign_attributes(weapon_params)
@weapon.ax_modifier1 = nil if weapon_params[:ax_modifier1] == -1
@weapon.ax_modifier2 = nil if weapon_params[:ax_modifier2] == -1
@weapon.ax_strength1 = nil if weapon_params[:ax_strength1]&.zero?
@weapon.ax_strength2 = nil if weapon_params[:ax_strength2]&.zero?
render json: GridWeaponBlueprint.render(@weapon, view: :nested) if @weapon.save
end
def destroy
render_unauthorized_response if @weapon.party.user != current_user
return render json: GridCharacterBlueprint.render(@weapon, view: :destroyed) if @weapon.destroy
end
def update_uncap_level
weapon = GridWeapon.find(weapon_params[:id])
object = weapon.weapon
max_uncap_level = max_uncap_level(object)
render_unauthorized_response if current_user && (weapon.party.user != current_user)
greater_than_max_uncap = weapon_params[:uncap_level].to_i > max_uncap_level
can_be_transcended = object.transcendence && weapon_params[:transcendence_step] && weapon_params[:transcendence_step]&.to_i&.positive?
uncap_level = if greater_than_max_uncap || can_be_transcended
max_uncap_level
else
weapon_params[:uncap_level]
end
transcendence_step = if object.transcendence && weapon_params[:transcendence_step]
weapon_params[:transcendence_step]
else
0
end
weapon.update!(
uncap_level: uncap_level,
transcendence_step: transcendence_step
) )
return unless weapon.persisted? if grid_weapon.persisted?
render json: GridWeaponBlueprint.render(grid_weapon, view: :full, root: :grid_weapon, meta: { replaced: resolve_params[:position] }), status: :created
else
render_validation_error_response(grid_weapon)
end
end
render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon) ##
# Destroys a GridWeapon.
#
# Checks authorization and, if allowed, destroys the weapon and renders the destroyed view.
#
# @return [void]
def destroy
grid_weapon = GridWeapon.find_by('id = ?', params[:id])
return render_not_found_response('grid_weapon') if grid_weapon.nil?
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
end end
private private
def max_uncap_level(weapon) ##
# Computes the maximum uncap level for a given weapon based on its flags.
#
# @param weapon [Weapon] the associated weapon.
# @return [Integer] the maximum allowed uncap level.
def compute_max_uncap_level(weapon)
if weapon.flb && !weapon.ulb && !weapon.transcendence if weapon.flb && !weapon.ulb && !weapon.transcendence
4 4
elsif weapon.ulb && !weapon.transcendence elsif weapon.ulb && !weapon.transcendence
@ -117,122 +150,213 @@ module Api
end end
end end
def check_weapon_compatibility ##
return if compatible_with_position?(incoming_weapon, weapon_params[:position]) # Computes the default uncap level for an incoming weapon.
#
raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: incoming_weapon) # This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
#
# @param incoming [Weapon] the incoming weapon.
# @return [Integer] the default uncap level.
def compute_default_uncap(incoming)
compute_max_uncap_level(incoming)
end end
# Check if the incoming weapon is compatible with the specified position ##
def compatible_with_position?(incoming_weapon, position) # Normalizes the AX modifier fields for the weapon parameters.
false if [9, 10, 11].include?(position.to_i) && ![11, 16, 17, 28, 29, 34].include?(incoming_weapon.series) #
true # Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
end #
# @return [void]
def conflict_weapon def normalize_ax_fields!
@conflict_weapon ||= find_conflict_weapon(party, incoming_weapon) params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
end
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
# 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 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) def render_grid_weapon_view(grid_weapon, conflict_position)
GridWeaponBlueprint.render(grid_weapon, view: :full, GridWeaponBlueprint.render(grid_weapon,
root: :grid_weapon, view: :full,
meta: { replaced: conflict_position }) root: :grid_weapon,
meta: { replaced: conflict_position })
end 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) def save_weapon(weapon)
# Check weapon validation and delete existing grid weapon # Check weapon validation and delete existing grid weapon if one already exists at position
# if one already exists at position if (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
if (grid_weapon = GridWeapon.where( existing.destroy
party_id: party.id,
position: weapon_params[:position]
).first)
GridWeapon.destroy(grid_weapon.id)
end end
# Set the party's element if the grid weapon is being set as mainhand # Set the party's element if the grid weapon is being set as mainhand
if weapon.position == -1 if weapon.position.to_i == -1
party.element = weapon.weapon.element @party.element = weapon.weapon.element
party.save! @party.save!
elsif [9, 10, 11].include?(weapon.position) elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i)
party.extra = true @party.extra = true
party.save! @party.save!
end end
# Render the weapon if it can be saved if weapon.save
return unless weapon.save output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
render json: output, status: :created
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon) else
render json: output, status: :created render_validation_error_response(weapon)
end
end end
##
# Handles conflicts when a new GridWeapon fails validation.
#
# Retrieves the array of conflicting grid weapons (via the models 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) def handle_conflict(weapon)
conflict_weapons = weapon.conflicts(party) conflict_weapons = weapon.conflicts(party)
# Find if one of the conflicting grid weapons is associated with the incoming weapon.
conflict_weapon = conflict_weapons.find { |gw| gw.weapon.id == incoming_weapon.id }
# Map conflict weapon IDs into an array if conflict_weapon.nil?
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]) output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
render json: output render json: output
else else
# Move the original grid weapon to the new position
# to preserve keys and other modifications
old_position = conflict_weapon.position old_position = conflict_weapon.position
conflict_weapon.position = weapon_params[:position] conflict_weapon.position = weapon_params[:position]
if conflict_weapon.save if conflict_weapon.save
output = render_grid_weapon_view(conflict_weapon, old_position) output = render_grid_weapon_view(conflict_weapon, old_position)
render json: output render json: output
else
render_validation_error_response(conflict_weapon)
end end
end end
end end
def set ##
@weapon = GridWeapon.where('id = ?', params[:id]).first # Renders the conflict view.
#
# @param conflict_weapons [Array<GridWeapon>] an array of conflicting grid weapons.
# @param incoming_weapon [Weapon] the incoming weapon.
# @param incoming_position [Integer] the desired position.
# @return [String] the rendered conflict view.
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
ConflictBlueprint.render(nil,
view: :weapons,
conflict_weapons: conflict_weapons,
incoming_weapon: incoming_weapon,
incoming_position: incoming_position)
end end
def authorize ##
# Create # Finds and sets the GridWeapon based on the provided parameters.
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) # Searches for a grid weapon using various parameter keys and renders a not found response if it is absent.
#
render_unauthorized_response if unauthorized_create || unauthorized_update # @return [void]
def find_grid_weapon
grid_weapon_id = params[:id] || params.dig(:weapon, :id) || params.dig(:resolve, :conflicting)
@grid_weapon = GridWeapon.find_by(id: grid_weapon_id)
render_not_found_response('grid_weapon') unless @grid_weapon
end end
# Specify whitelisted properties that can be modified. ##
# Finds and sets the incoming weapon.
#
# @return [void]
def find_incoming_weapon
if params.dig(:weapon, :weapon_id).present?
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
render_not_found_response('weapon') unless @incoming_weapon
else
@incoming_weapon = nil
end
end
##
# Finds and sets the party based on parameters.
#
# Renders an unauthorized response if the current user is not the owner.
#
# @return [void]
def find_party
@party = Party.find_by(id: params.dig(:weapon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_weapon&.party
render_not_found_response('party') unless @party
end
##
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
#
# For parties associated with a user, it verifies that the current_user is the owner.
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
#
# @return [void]
def authorize_party_edit!
if @party.user.present?
authorize_user_party
else
authorize_anonymous_party
end
end
##
# Authorizes an action for a party that belongs to a user.
#
# Renders an unauthorized response unless the current user is present and
# matches the party's user.
#
# @return [void]
def authorize_user_party
return if current_user.present? && @party.user == current_user
return render_unauthorized_response
end
##
# Authorizes an action for an anonymous party using an edit key.
#
# Retrieves and normalizes the provided edit key and compares it with the party's edit key.
# Renders an unauthorized response unless the keys are valid.
#
# @return [void]
def authorize_anonymous_party
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
return if valid_edit_key?(provided_edit_key, party_edit_key)
return render_unauthorized_response
end
##
# Validates that the provided edit key matches the party's edit key.
#
# @param provided_edit_key [String] the edit key provided in the request.
# @param party_edit_key [String] the edit key associated with the party.
# @return [Boolean] true if the edit keys match; false otherwise.
def valid_edit_key?(provided_edit_key, party_edit_key)
provided_edit_key.present? &&
provided_edit_key.bytesize == party_edit_key.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
##
# Specifies and permits the allowed weapon parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def weapon_params def weapon_params
params.require(:weapon).permit( params.require(:weapon).permit(
:id, :party_id, :weapon_id, :id, :party_id, :weapon_id,
@ -243,6 +367,10 @@ module Api
) )
end end
##
# Specifies and permits the resolve parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def resolve_params def resolve_params
params.require(:resolve).permit(:position, :incoming, conflicting: []) params.require(:resolve).permit(:position, :incoming, conflicting: [])
end end

View file

@ -2,6 +2,20 @@
module Api module Api
module V1 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 class ImportController < Api::V1::ApiController
ELEMENT_MAPPING = { ELEMENT_MAPPING = {
0 => nil, 0 => nil,
@ -13,263 +27,189 @@ module Api
6 => 5 6 => 5
}.freeze }.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 def create
Rails.logger.info "[IMPORT] Starting import..." Rails.logger.info '[IMPORT] Checking input...'
# Parse JSON request body body = parse_request_body
raw_body = request.raw_post return unless body
begin
raw_params = JSON.parse(raw_body) if raw_body.present? raw_params = body['import']
Rails.logger.info "[IMPORT] Raw game data: #{raw_params.inspect}" unless raw_params.is_a?(Hash)
rescue JSON::ParserError => e Rails.logger.error "[IMPORT] 'import' key is missing or not a hash."
Rails.logger.error "[IMPORT] Invalid JSON in request body: #{e.message}" return render json: { error: 'Invalid JSON data' }, status: :unprocessable_content
render json: { error: 'Invalid JSON data' }, status: :bad_request
return
end end
if raw_params.nil? || !raw_params.is_a?(Hash) unless raw_params['deck'].is_a?(Hash) &&
Rails.logger.error "[IMPORT] Missing or invalid game data" raw_params['deck'].key?('pc') &&
render json: { error: 'Missing or invalid game data' }, status: :bad_request raw_params['deck'].key?('npc')
return Rails.logger.error '[IMPORT] Deck data incomplete or missing.'
return render json: { error: 'Invalid deck data' }, status: :unprocessable_content
end end
# Transform game data Rails.logger.info '[IMPORT] Starting import...'
transformer = ::Granblue::Transformers::BaseDeckTransformer.new(raw_params)
transformed_data = transformer.transform
Rails.logger.info "[IMPORT] Transformed data: #{transformed_data.inspect}"
# Validate transformed data return if performed? # Rendered an error response already
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
# Create party party = Party.create(user: current_user)
party = Party.new(user: current_user) deck_data = raw_params['import']
process_data(party, deck_data)
ActiveRecord::Base.transaction do
# Basic party data
party.name = transformed_data[:name]
party.extra = transformed_data[:extra]
party.save!
# Process job and skills
if transformed_data[:class].present?
process_job(party, transformed_data[:class], transformed_data[:subskills])
end
# Process characters
if transformed_data[:characters].present?
process_characters(party, transformed_data[:characters])
end
# Process weapons
if transformed_data[:weapons].present?
process_weapons(party, transformed_data[:weapons])
end
# Process summons
if transformed_data[:summons].present?
process_summons(party, transformed_data[:summons], transformed_data[:friend_summon])
end
# Process sub summons
if transformed_data[:sub_summons].present?
process_sub_summons(party, transformed_data[:sub_summons])
end
end
# Return shortcode for redirection
render json: { shortcode: party.shortcode }, status: :created render json: { shortcode: party.shortcode }, status: :created
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[IMPORT] Error processing import: #{e.message}" render json: { error: e.message }, status: :unprocessable_content
Rails.logger.error "[IMPORT] Backtrace: #{e.backtrace.join("\n")}" end
render json: { error: 'Error processing import' }, status: :unprocessable_entity
def weapons
Rails.logger.info '[IMPORT] Checking weapon gamedata input...'
body = parse_request_body
return unless body
weapon = Weapon.find_by(granblue_id: body['id'])
unless weapon
Rails.logger.error "[IMPORT] Weapon not found with ID: #{body['id']}"
return render json: { error: 'Weapon not found' }, status: :not_found
end
lang = params[:lang]
unless %w[en jp].include?(lang)
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
return render json: { error: 'Invalid language' }, status: :unprocessable_content
end
begin
weapon.update!(
"game_raw_#{lang}" => body.to_json
)
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
render json: { error: e.message }, status: :unprocessable_content
end
end
def summons
Rails.logger.info '[IMPORT] Checking summon gamedata input...'
body = parse_request_body
return unless body
summon = Summon.find_by(granblue_id: body['id'])
unless summon
Rails.logger.error "[IMPORT] Summon not found with ID: #{body['id']}"
return render json: { error: 'Summon not found' }, status: :not_found
end
lang = params[:lang]
unless %w[en jp].include?(lang)
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
return render json: { error: 'Invalid language' }, status: :unprocessable_content
end
begin
summon.update!(
"game_raw_#{lang}" => body.to_json
)
render json: { message: 'Summon gamedata updated successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
render json: { error: e.message }, status: :unprocessable_content
end
end
##
# Updates character gamedata from JSON blob.
#
# @return [void] Renders JSON response with success or error message.
def characters
Rails.logger.info '[IMPORT] Checking character gamedata input...'
body = parse_request_body
return unless body
character = Character.find_by(granblue_id: body['id'])
unless character
Rails.logger.error "[IMPORT] Character not found with ID: #{body['id']}"
return render json: { error: 'Character not found' }, status: :not_found
end
lang = params[:lang]
unless %w[en jp].include?(lang)
Rails.logger.error "[IMPORT] Invalid language: #{lang}"
return render json: { error: 'Invalid language' }, status: :unprocessable_content
end
begin
character.update!(
"game_raw_#{lang}" => body.to_json
)
render json: { message: 'Character gamedata updated successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"
render json: { error: e.message }, status: :unprocessable_content
end
end end
private private
def process_job(party, job_name, subskills) ##
return unless job_name # Ensures the current user has admin role (role 9).
job = Job.find_by("name_en = ? OR name_jp = ?", job_name, job_name) # Renders an error if the user is not an admin.
unless job #
Rails.logger.warn "[IMPORT] Could not find job: #{job_name}" # @return [void]
return def ensure_admin_role
end return if current_user&.role == 9
party.job = job Rails.logger.error "[IMPORT] Unauthorized access attempt by user #{current_user&.id}"
party.save! render json: { error: 'Unauthorized' }, status: :unauthorized
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 end
def process_characters(party, characters) ##
return unless characters&.any? # Reads and parses the raw JSON request body.
Rails.logger.info "[IMPORT] Processing #{characters.length} characters" #
# @return [Hash] Parsed JSON data.
characters.each_with_index do |char_data, idx| # @raise [JSON::ParserError] If the JSON is invalid.
character = Character.find_by(granblue_id: char_data[:id]) def parse_request_body
unless character raw_body = request.raw_post
Rails.logger.warn "[IMPORT] Character not found: #{char_data[:id]}" JSON.parse(raw_body)
next rescue JSON::ParserError => e
end Rails.logger.error "[IMPORT] Invalid JSON: #{e.message}"
render json: { error: 'Invalid JSON data' }, status: :bad_request and return
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 end
def process_weapons(party, weapons) ##
return unless weapons&.any? # Ensures that the provided data is wrapped under an "import" key.
Rails.logger.info "[IMPORT] Processing #{weapons.length} weapons" #
# @param data [Hash] The parsed JSON data.
weapons.each_with_index do |weapon_data, idx| # @return [Hash] Data wrapped under the "import" key.
weapon = Weapon.find_by(granblue_id: weapon_data[:id]) def wrap_import_data(data)
unless weapon data.key?('import') ? data : { 'import' => data }
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 end
def process_weapon_keys(grid_weapon, keys) ##
keys.each_with_index do |key_id, idx| # Processes the deck data using processors.
key = WeaponKey.find_by(granblue_id: key_id) #
unless key # @param party [Party] The party to insert data into
Rails.logger.warn "[IMPORT] WeaponKey not found: #{key_id}" # @param data [Hash] The wrapped data.
next # @return [Hash] The transformed deck data.
end def process_data(party, data)
grid_weapon["weapon_key#{idx + 1}_id"] = key.id Rails.logger.info '[IMPORT] Transforming deck data'
grid_weapon.save!
end
end
def process_weapon_ax(grid_weapon, ax_skills) Processors::JobProcessor.new(party, data).process
ax_skills.each_with_index do |ax, idx| Processors::CharacterProcessor.new(party, data).process
grid_weapon["ax_modifier#{idx + 1}"] = ax[:id].to_i Processors::SummonProcessor.new(party, data).process
grid_weapon["ax_strength#{idx + 1}"] = ax[:val].to_s.gsub(/[+%]/, '').to_i Processors::WeaponProcessor.new(party, data).process
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 end
end end

View file

@ -4,11 +4,13 @@ module Api
module V1 module V1
class JobSkillsController < Api::V1::ApiController class JobSkillsController < Api::V1::ApiController
def all def all
render json: JobSkillBlueprint.render(JobSkill.all) render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
end end
def job def job
@skills = JobSkill.where('job_id != ? AND emp = ?', params[:id], true) @skills = JobSkill.includes(:job)
.where.not(job_id: params[:id])
.where(emp: true)
render json: JobSkillBlueprint.render(@skills) render json: JobSkillBlueprint.render(@skills)
end end
end end

View file

@ -34,10 +34,10 @@ module Api
# Remove extra subskills if necessary # Remove extra subskills if necessary
if old_job && if old_job &&
%w[1 2 3].include?(old_job.row) && %w[1 2 3].include?(old_job.row) &&
%w[4 5 ex2].include?(job.row) && %w[4 5 ex2].include?(job.row) &&
@party.skill1 && @party.skill2 && @party.skill3 && @party.skill1 && @party.skill2 && @party.skill3 &&
@party.skill1.sub && @party.skill2.sub && @party.skill3.sub @party.skill1.sub && @party.skill2.sub && @party.skill3.sub
@party['skill3_id'] = nil @party['skill3_id'] = nil
end end
else else
@ -47,7 +47,7 @@ module Api
end end
end end
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save! render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
end end
def update_job_skills def update_job_skills

View file

@ -2,437 +2,197 @@
module Api module Api
module V1 module V1
# Controller for managing party-related operations in the API
# @api public
class PartiesController < Api::V1::ApiController class PartiesController < Api::V1::ApiController
before_action :set_from_slug, include PartyAuthorizationConcern
except: %w[create destroy update index favorites] include PartyQueryingConcern
before_action :set, only: %w[update destroy] include PartyPreviewConcern
before_action :authorize, only: %w[update destroy]
# Constants used for filtering validations.
# Maximum number of characters allowed in a party
MAX_CHARACTERS = 5 MAX_CHARACTERS = 5
# Maximum number of summons allowed in a party
MAX_SUMMONS = 8 MAX_SUMMONS = 8
# Maximum number of weapons allowed in a party
MAX_WEAPONS = 13 MAX_WEAPONS = 13
# Default minimum number of characters required for filtering
DEFAULT_MIN_CHARACTERS = 3 DEFAULT_MIN_CHARACTERS = 3
# Default minimum number of summons required for filtering
DEFAULT_MIN_SUMMONS = 2 DEFAULT_MIN_SUMMONS = 2
# Default minimum number of weapons required for filtering
DEFAULT_MIN_WEAPONS = 5 DEFAULT_MIN_WEAPONS = 5
# Default maximum clear time in seconds
DEFAULT_MAX_CLEAR_TIME = 5400 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 def create
party = Party.new party = Party.new(party_params)
party.user = current_user if current_user party.user = current_user if current_user
party.attributes = party_params if party_params if party_params && party_params[:raid_id].present?
if (raid = Raid.find_by(id: party_params[:raid_id]))
if party_params && party_params[:raid_id] party.extra = raid.group.extra
raid = Raid.find_by(id: party_params[:raid_id]) end
party.extra = raid.group.extra
end end
if party.save
if party.save! party.schedule_preview_generation if party.ready_for_preview?
return render json: PartyBlueprint.render(party, view: :created, root: :party), render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
status: :created else
render_validation_error_response(party)
end end
render_validation_error_response(@party)
end end
# Shows a specific party.
def show def show
# If a party is private, check that the user is the owner or an admin return render_unauthorized_response if @party.private? && (!current_user || not_owner?)
if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode)
return render_unauthorized_response if @party
render json: PartyBlueprint.render(@party, view: :full, root: :party)
else
render_not_found_response('project')
end end
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party
render_not_found_response('project')
end end
# Updates an existing party.
def update def update
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id) @party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
if party_params && party_params[:raid_id] if party_params && party_params[:raid_id]
raid = Raid.find_by(id: party_params[:raid_id]) if (raid = Raid.find_by(id: party_params[:raid_id]))
@party.extra = raid.group.extra @party.extra = raid.group.extra
end
end
if @party.save
render json: PartyBlueprint.render(@party, view: :full, root: :party)
else
render_validation_error_response(@party)
end 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 end
# Deletes a party.
def destroy def destroy
return render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
end end
# Extended Party Actions
# Creates a remixed copy of an existing party.
def remix def remix
new_party = @party.amoeba_dup new_party = @party.amoeba_dup
new_party.attributes = { new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
user: current_user, new_party.local_id = party_params[:local_id] if party_params
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 if new_party.save
render json: PartyBlueprint.render(new_party, view: :created, root: :party), new_party.schedule_preview_generation
status: :created render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created
else else
render_validation_error_response(new_party) render_validation_error_response(new_party)
end end
end end
# Lists parties based on query parameters.
def index def index
conditions = build_filters query = build_filtered_query(build_common_base_query)
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
query = build_query(conditions) render_paginated_parties(@parties)
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 end
# GET /api/v1/parties/favorites
def favorites def favorites
raise Api::V1::UnauthorizedError unless current_user raise Api::V1::UnauthorizedError unless current_user
conditions = build_filters base_query = build_common_base_query
conditions[:favorites] = { user_id: current_user.id } .joins(:favorites)
.where(favorites: { user_id: current_user.id })
query = build_query(conditions, favorites: true) .distinct
query = apply_includes(query, params[:includes]) if params[:includes].present? query = build_filtered_query(base_query)
query = apply_excludes(query, params[:excludes]) if params[:excludes].present? @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
render_paginated_parties(@parties)
@parties = fetch_parties(query)
count = calculate_count(query)
total_pages = calculate_total_pages(count)
render_party_json(@parties, count, total_pages)
end end
# Preview Management
# Serves the party's preview image
# @return [void]
# Serves the party's preview image.
def preview def preview
party = Party.find_by!(shortcode: params[:id]) party_preview(@party)
preview_service = PreviewService::Coordinator.new(party)
redirect_to preview_service.preview_url
end 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? }
end
# Forces regeneration of the party preview.
def regenerate_preview def regenerate_preview
party = Party.find_by!(shortcode: params[:id]) 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) preview_service = PreviewService::Coordinator.new(party)
if preview_service.force_regenerate if preview_service.force_regenerate
render json: { status: 'Preview regeneration started' } render json: { status: 'Preview regeneration started' }
else else
render json: { error: 'Preview regeneration failed' }, render json: { error: 'Preview regeneration failed' }, status: :unprocessable_entity
status: :unprocessable_entity
end end
end end
private private
def authorize # Loads the party by its shortcode.
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 def set_from_slug
@party = Party.where('shortcode = ?', params[:id]).first @party = Party.includes(
if @party :user, :job, { raid: :group },
@party.favorited = current_user && @party ? @party.is_favorited(current_user) : false { characters: %i[character awakening] },
else { weapons: {
render_not_found_response('party') weapon: [:awakenings],
end 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
end end
# Loads the party by its id.
def set def set
@party = Party.where('id = ?', params[:id]).first @party = Party.where('id = ?', params[:id]).first
end end
# Sanitizes and permits party parameters.
def party_params def party_params
return unless params[:party].present? return unless params[:party].present?
params.require(:party).permit( params.require(:party).permit(
:user_id, :user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
:local_id, :accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
:edit_key, :full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
:extra, :turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id,
:name, characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
:description, :transcendence_step, :perpetuity, :awakening_id, :awakening_level,
:raid_id, { ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
: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] }], earring: %i[modifier strength] }],
summons_attributes: %i[id party_id summon_id position main friend summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
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]
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
end end

View file

@ -4,7 +4,7 @@ module Api
module V1 module V1
class RaidsController < Api::V1::ApiController class RaidsController < Api::V1::ApiController
def all def all
render json: RaidBlueprint.render(Raid.all, view: :full) render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested)
end end
def show def show
@ -13,7 +13,7 @@ module Api
end end
def groups def groups
render json: RaidGroupBlueprint.render(RaidGroup.all, view: :full) render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full)
end end
end end
end end

View file

@ -6,7 +6,7 @@ module Api
class ForbiddenError < StandardError; end class ForbiddenError < StandardError; end
before_action :set, except: %w[create check_email check_username] before_action :set, except: %w[create check_email check_username]
before_action :set_by_id, only: %w[info update] before_action :set_by_id, only: %w[update]
MAX_CHARACTERS = 5 MAX_CHARACTERS = 5
MAX_SUMMONS = 8 MAX_SUMMONS = 8
@ -55,32 +55,39 @@ module Api
if @user.nil? if @user.nil?
render_not_found_response('user') render_not_found_response('user')
else else
conditions = build_conditions base_query = Party.includes(
conditions[:user_id] = @user.id { raid: :group },
:job,
parties = Party :user,
.where(conditions) :skill0,
.where(name_quality) :skill1,
.where(user_quality) :skill2,
.where(original) :skill3,
.where(privacy) :guidebook1,
.order(created_at: :desc) :guidebook2,
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE) :guidebook3,
.each do |party| { characters: :character },
party.favorited = current_user ? party.is_favorited(current_user) : false { weapons: :weapon },
end { summons: :summon }
)
count = Party.where(conditions).count # Restrict to parties belonging to the profile owner
base_query = base_query.where(user_id: @user.id)
skip_privacy = (current_user&.id == @user.id)
query = PartyQueryBuilder.new(
base_query,
params: params,
current_user: current_user,
options: { skip_privacy: skip_privacy }
).build
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE)
count = query.count
render json: UserBlueprint.render(@user, render json: UserBlueprint.render(@user,
view: :profile, view: :profile,
root: 'profile', root: 'profile',
parties: parties, parties: parties,
meta: { meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
count: count, current_user: current_user
total_pages: count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1, )
per_page: COLLECTION_PER_PAGE
})
end end
end end
@ -96,12 +103,42 @@ module Api
private 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 profiles 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 def build_conditions
params = request.params params = request.params
unless params['recency'].blank? unless params['recency'].blank?
start_time = (DateTime.current - params['recency'].to_i.seconds) start_time = (DateTime.current - params['recency'].to_i.seconds)
.to_datetime.beginning_of_day .to_datetime.beginning_of_day
end end
min_characters_count = params['characters_count'].blank? ? DEFAULT_MIN_CHARACTERS : params['characters_count'].to_i min_characters_count = params['characters_count'].blank? ? DEFAULT_MIN_CHARACTERS : params['characters_count'].to_i

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
module PartyAuthorizationConcern
extend ActiveSupport::Concern
# Checks whether the current user (or provided edit key) is authorized to modify @party.
def authorize_party!
if @party.user.present?
render_unauthorized_response unless current_user.present? && @party.user == current_user
else
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
render_unauthorized_response unless valid_edit_key?(provided_edit_key, party_edit_key)
end
end
# Returns true if the party does not belong to the current user.
def not_owner?
if @party.user
return true if current_user && @party.user != current_user
return true if current_user.nil? && edit_key.present?
else
return true if current_user.present?
return true if current_user.nil? && (@party.edit_key != edit_key)
end
false
end
# Verifies that the provided edit key matches the party's edit key.
def valid_edit_key?(provided_edit_key, party_edit_key)
provided_edit_key.present? &&
provided_edit_key.bytesize == party_edit_key.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
module PartyPreviewConcern
extend ActiveSupport::Concern
# Schedules preview generation for this party.
def schedule_preview_generation
GeneratePartyPreviewJob.perform_later(id)
end
# Handles serving the party preview image.
def party_preview(party)
coordinator = PreviewService::Coordinator.new(party)
if coordinator.generation_in_progress?
response.headers['Retry-After'] = '2'
default_path = Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png")
send_file default_path, type: 'image/png', disposition: 'inline'
return
end
begin
if Rails.env.production?
s3_object = coordinator.get_s3_object
send_data s3_object.body.read, filename: "#{party.shortcode}.png", type: 'image/png', disposition: 'inline'
else
send_file coordinator.local_preview_path, type: 'image/png', disposition: 'inline'
end
rescue Aws::S3::Errors::NoSuchKey
coordinator.schedule_generation unless coordinator.generation_in_progress?
send_file Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png"), type: 'image/png', disposition: 'inline'
end
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
module PartyQueryingConcern
extend ActiveSupport::Concern
include PartyConstants
# Returns the common base query for Parties including all necessary associations.
def build_common_base_query
Party.includes(
{ raid: :group },
:job,
:user,
:skill0,
:skill1,
:skill2,
:skill3,
:guidebook1,
:guidebook2,
:guidebook3,
{ characters: :character },
{ weapons: :weapon },
{ summons: :summon }
)
end
# Uses PartyQueryBuilder to apply additional filters (includes, excludes, date ranges, etc.)
def build_filtered_query(base_query)
PartyQueryBuilder.new(base_query,
params: params,
current_user: current_user,
options: { apply_defaults: true }).build
end
# Renders paginated parties using PartyBlueprint.
def render_paginated_parties(parties)
render json: Api::V1::PartyBlueprint.render(
parties,
view: :preview,
root: :results,
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: COLLECTION_PER_PAGE
},
current_user: current_user
)
end
# Returns a remixed party name based on the current party name and current_user language.
def remixed_name(name)
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
if current_user
case current_user.language
when 'en' then "Remix of #{blanked_name[:en]}"
when 'ja' then "#{blanked_name[:ja]}のリミックス"
else "Remix of #{blanked_name[:en]}"
end
else
"Remix of #{blanked_name[:en]}"
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Api
module V1
class NoCharacterProvidedError < GranblueError
def http_status
422
end
def code
'no_character_provided'
end
def message
'A valid character must be provided'
end
def to_hash
{
message: message,
code: code
}
end
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Api
module V1
class NoSummonProvidedError < GranblueError
def http_status
422
end
def code
'no_summon_provided'
end
def message
'A valid summon must be provided'
end
def to_hash
{
message: message,
code: code
}
end
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Api
module V1
class NoWeaponProvidedError < GranblueError
def http_status
422
end
def code
'no_weapon_provided'
end
def message
'A valid weapon must be provided'
end
def to_hash
{
message: message,
code: code
}
end
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
#
# This module contains shared constants used for querying and filtering Party resources.
# It is included by controllers and concerns that require these configuration values.
#
module PartyConstants
COLLECTION_PER_PAGE = 15
DEFAULT_MIN_CHARACTERS = 3
DEFAULT_MIN_SUMMONS = 2
DEFAULT_MIN_WEAPONS = 5
MAX_CHARACTERS = 5
MAX_SUMMONS = 8
MAX_WEAPONS = 13
DEFAULT_MAX_CLEAR_TIME = 5400
end

View file

@ -1,13 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Awakening < ApplicationRecord class Awakening < ApplicationRecord
def weapon_awakenings has_many :weapon_awakenings, foreign_key: :awakening_id
WeaponAwakening.where(awakening_id: id) has_many :weapons, through: :weapon_awakenings
end
def weapons
weapon_awakenings.map(&:weapon)
end
def awakening def awakening
AwakeningBlueprint AwakeningBlueprint

View file

@ -34,6 +34,13 @@ 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 def blueprint
CharacterBlueprint CharacterBlueprint
end end
@ -41,73 +48,4 @@ class Character < ApplicationRecord
def display_resource(character) def display_resource(character)
character.name_en character.name_en
end 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 end

View file

@ -0,0 +1,34 @@
module GranblueEnums
extend ActiveSupport::Concern
# Define constants for shared enum mappings.
RARITIES = { R: 1, SR: 2, SSR: 3 }.freeze
ELEMENTS = { Null: 0, Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 }.freeze
GENDERS = { Unknown: 0, Male: 1, Female: 2, "Male/Female": 3 }.freeze
# Single proficiency enum mapping used for both proficiency1 and proficiency2.
PROFICIENCY = {
None: 0,
Sabre: 1,
Dagger: 2,
Axe: 3,
Spear: 4,
Bow: 5,
Staff: 6,
Melee: 7,
Harp: 8,
Gun: 9,
Katana: 10
}.freeze
# Single race enum mapping used for both race1 and race2.
RACES = {
Unknown: 0,
Human: 1,
Erune: 2,
Draph: 3,
Harvin: 4,
Primal: 5,
None: 6
}.freeze
end

View file

@ -1,19 +1,44 @@
# frozen_string_literal: true # 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 class GridCharacter < ApplicationRecord
has_one :object, class_name: 'Character', foreign_key: :id, primary_key: :character_id # Associations
belongs_to :character, foreign_key: :character_id, primary_key: :id
belongs_to :awakening, optional: true belongs_to :awakening, optional: true
belongs_to :party, belongs_to :party,
counter_cache: :characters_count, counter_cache: :characters_count,
inverse_of: :characters inverse_of: :characters
# Validations
validates_presence_of :party 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 :validate_awakening_level, on: :update
validate :transcendence, on: :update validate :transcendence, on: :update
validate :validate_over_mastery_values, on: :update validate :validate_over_mastery_values, on: :update
validate :validate_aetherial_mastery_value, on: :update validate :validate_aetherial_mastery_value, on: :update
validate :over_mastery_attack_matches_hp, on: :update
# Virtual attributes
attr_accessor :new_rings
attr_accessor :new_awakening
##### Amoeba configuration ##### Amoeba configuration
amoeba do amoeba do
@ -25,49 +50,121 @@ class GridCharacter < ApplicationRecord
set perpetuity: false set perpetuity: false
end end
# Add awakening before the model saves # Hooks
before_validation :apply_new_rings, if: -> { new_rings.present? }
before_validation :apply_new_awakening, if: -> { new_awakening.present? }
before_save :add_awakening 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 def validate_awakening_level
errors.add(:awakening, 'awakening level too low') if awakening_level < 1 errors.add(:awakening, 'awakening level too low') if awakening_level < 1
errors.add(:awakening, 'awakening level too high') if awakening_level > 9 errors.add(:awakening, 'awakening level too high') if awakening_level > 9
end 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 def transcendence
errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb 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 high') if transcendence_step > 5 && character.ulb
errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb
end 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 def over_mastery_attack
errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength']) errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength'])
end 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 def over_mastery_hp
return if ring2['modifier'].nil? return if ring2['modifier'].nil?
errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength']) errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength'])
end end
def over_mastery_attack_matches_hp ##
return if ring1[:modifier].nil? && ring2[:modifier].nil? # Validates over mastery values by invoking individual and cross-field validations.
#
return if ring2[:strength] == (ring1[:strength] / 2) # This method triggers:
# - Validation for individual over mastery values for rings 1-4.
errors.add(:over_mastery, # - Validation ensuring that ring1's attack and ring2's HP values are consistent.
'over mastery attack and hp values do not match') #
# @return [void]
def validate_over_mastery_values
validate_individual_over_mastery_values
validate_over_mastery_attack_matches_hp
end end
def validate_over_mastery_values ##
# Validates individual over mastery values for each ring (ring1 to ring4).
#
# Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength
# is within the permitted range based on over mastery rules.
#
# @return [void]
def validate_individual_over_mastery_values
# Iterate over rings 1-4 and check each rings value.
[ring1, ring2, ring3, ring4].each_with_index do |ring, index| [ring1, ring2, ring3, ring4].each_with_index do |ring, index|
next if ring['modifier'].nil? next if ring['modifier'].nil?
modifier = over_mastery_modifiers[ring['modifier']] modifier = over_mastery_modifiers[ring['modifier']]
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, # Use a helper to add errors if the value is out-of-range.
'over_mastery') check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
end end
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 def validate_aetherial_mastery_value
return if earring['modifier'].nil? return if earring['modifier'].nil?
@ -78,22 +175,72 @@ class GridCharacter < ApplicationRecord
'aetherial_mastery') 'aetherial_mastery')
end end
def character ##
Character.find(character_id) # Returns the blueprint for rendering the grid character.
end #
# @return [GridCharacterBlueprint] the blueprint class used for grid character representation.
def blueprint def blueprint
GridCharacterBlueprint GridCharacterBlueprint
end end
private 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 def add_awakening
return unless awakening.nil? return unless awakening.nil?
self.awakening = Awakening.where(slug: 'character-balanced').sole self.awakening = Awakening.where(slug: 'character-balanced').sole
end 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) def check_value(property, type)
# Input format # Input format
# { ring1: { atk: 300 } } # { ring1: { atk: 300 } }
@ -112,6 +259,10 @@ class GridCharacter < ApplicationRecord
end end
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 def over_mastery_modifiers
{ {
1 => 'atk', 1 => 'atk',
@ -132,6 +283,10 @@ class GridCharacter < ApplicationRecord
} }
end 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 def over_mastery_values
{ {
atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000], atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
@ -152,6 +307,9 @@ class GridCharacter < ApplicationRecord
} }
end 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 def aetherial_mastery_modifiers
{ {
1 => 'da', 1 => 'da',
@ -167,6 +325,10 @@ class GridCharacter < ApplicationRecord
} }
end 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 def aetherial_mastery_values
{ {
da: { da: {
@ -212,10 +374,18 @@ class GridCharacter < ApplicationRecord
} }
end end
##
# Returns an array of valid attack values for over mastery validation.
#
# @return [Array<Integer>] list of allowed attack values.
def atk_values def atk_values
[300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000] [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]
end end
##
# Returns an array of valid HP values for over mastery validation.
#
# @return [Array<Integer>] list of allowed HP values.
def hp_values def hp_values
[150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500] [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500]
end end

View file

@ -1,24 +1,52 @@
# frozen_string_literal: true # 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 summons flags.
#
# @!attribute [r] summon
# @return [Summon] the associated summon.
# @!attribute [r] party
# @return [Party] the associated party.
class GridSummon < ApplicationRecord class GridSummon < ApplicationRecord
belongs_to :summon, foreign_key: :summon_id, primary_key: :id
belongs_to :party, belongs_to :party,
counter_cache: :summons_count, counter_cache: :summons_count,
inverse_of: :summons inverse_of: :summons
validates_presence_of :party 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 :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 Summons flags.
validate :validate_uncap_level_based_on_summon_flags
validate :no_conflicts, on: :create validate :no_conflicts, on: :create
def summon ##
Summon.find(summon_id) # Returns the blueprint for rendering the grid summon.
end #
# @return [GridSummonBlueprint] the blueprint class for grid summons.
def blueprint def blueprint
GridSummonBlueprint GridSummonBlueprint
end end
# Returns conflicting summons if they exist ##
# Returns any conflicting grid summon for the given party.
#
# If the associated summon has a limit, this method searches the party's grid summons to find
# any that conflict based on the summon ID.
#
# @param party [Party] the party in which to check for conflicts.
# @return [GridSummon, nil] the conflicting grid summon if found, otherwise nil.
def conflicts(party) def conflicts(party)
return unless summon.limit return unless summon.limit
@ -31,13 +59,74 @@ class GridSummon < ApplicationRecord
private private
# Validates whether there is a conflict with the party ##
# Validates the uncap_level based on the associated Summons flags.
#
# This method delegates to specific validation methods for FLB, ULB, and transcendence limits.
#
# @return [void]
def validate_uncap_level_based_on_summon_flags
return unless summon
validate_flb_limit
validate_ulb_limit
validate_transcendence_limits
end
##
# Validates that the uncap_level does not exceed 3 if the associated Summon does not have the FLB flag.
#
# @return [void]
def validate_flb_limit
return unless !summon.flb && uncap_level.to_i > 3
errors.add(:uncap_level, 'cannot be greater than 3 if summon does not have FLB')
end
##
# Validates that the uncap_level does not exceed 4 if the associated Summon does not have the ULB flag.
#
# @return [void]
def validate_ulb_limit
return unless !summon.ulb && uncap_level.to_i > 4
errors.add(:uncap_level, 'cannot be greater than 4 if summon does not have ULB')
end
##
# Validates the uncap_level and transcendence_step based on whether the associated Summon supports transcendence.
#
# If the summon does not support transcendence, the uncap_level must not exceed 5 and the transcendence_step must be 0.
#
# @return [void]
def validate_transcendence_limits
return if summon.transcendence
errors.add(:uncap_level, 'cannot be greater than 5 if summon does not have transcendence') if uncap_level.to_i > 5
return unless transcendence_step.to_i.positive?
errors.add(:transcendence_step, 'must be 0 if summon does not have transcendence')
end
##
# Validates that there are no conflicting grid summons in the party.
#
# If a conflict is found (i.e. another grid summon exists that conflicts with this one),
# an error is added to the :series attribute.
#
# @return [void]
def no_conflicts def no_conflicts
# Check if the grid summon conflicts with any of the other grid summons in the party # 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? errors.add(:series, 'must not conflict with existing summons') unless conflicts(party).nil?
end end
# Validates whether the summon can be added to the desired position ##
# Validates whether the grid summon can be added to the desired position.
#
# For positions 4 and 5, the associated summon must have subaura; otherwise, an error is added.
#
# @return [void]
def compatible_with_position def compatible_with_position
return unless [4, 5].include?(position.to_i) && !summon.subaura return unless [4, 5].include?(position.to_i) && !summon.subaura

View file

@ -1,13 +1,37 @@
# frozen_string_literal: true # 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 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, belongs_to :party,
counter_cache: :weapons_count, counter_cache: :weapons_count,
inverse_of: :weapons inverse_of: :weapons
validates_presence_of :party 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_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_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 belongs_to :weapon_key3, class_name: 'WeaponKey', foreign_key: :weapon_key3_id, optional: true
@ -15,10 +39,14 @@ class GridWeapon < ApplicationRecord
belongs_to :awakening, optional: true 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 :compatible_with_position, on: :create
validate :no_conflicts, on: :create validate :no_conflicts, on: :create
before_save :mainhand? before_save :assign_mainhand
##### Amoeba configuration ##### Amoeba configuration
amoeba do amoeba do
@ -28,73 +56,99 @@ class GridWeapon < ApplicationRecord
nullify :ax_strength2 nullify :ax_strength2
end end
# Helper methods ##
# Returns the blueprint for rendering the grid weapon.
#
# @return [GridWeaponBlueprint] the blueprint class for grid weapons.
def blueprint def blueprint
GridWeaponBlueprint GridWeaponBlueprint
end end
def weapon ##
Weapon.find(weapon_id) # Returns an array of assigned weapon keys.
end #
# This method returns an array containing weapon_key1, weapon_key2, and weapon_key3,
# omitting any nil values.
#
# @return [Array<WeaponKey>] the non-nil weapon keys.
def weapon_keys def weapon_keys
[weapon_key1, weapon_key2, weapon_key3].compact [weapon_key1, weapon_key2, weapon_key3].compact
end end
# Returns conflicting weapons if they exist ##
# Returns conflicting grid weapons within a given party.
#
# Checks if the associated weapon is present, responds to a :limit method, and is limited.
# It then iterates over the party's grid weapons and selects those that conflict with this one,
# based on series matching or specific conditions related to opus or draconic status.
#
# @param party [Party] the party in which to check for conflicts.
# @return [ActiveRecord::Relation<GridWeapon>] an array of conflicting grid weapons (empty if none are found).
def conflicts(party) def conflicts(party)
return unless weapon.limit return [] unless weapon.present? && weapon.respond_to?(:limit) && weapon.limit
conflicting_weapons = [] party.weapons.select do |party_weapon|
# Skip if the record is not persisted.
party.weapons.each do |party_weapon| next false unless party_weapon.id.present?
next unless party_weapon.id
id_match = weapon.id == party_weapon.id id_match = weapon.id == party_weapon.id
series_match = weapon.series == party_weapon.weapon.series series_match = weapon.series == party_weapon.weapon.series
both_opus_or_draconic = weapon.opus_or_draconic? && party_weapon.weapon.opus_or_draconic? 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? both_draconic = weapon.draconic_or_providence? && party_weapon.weapon.draconic_or_providence?
conflicting_weapons << party_weapon if (series_match || both_opus_or_draconic || both_draconic) && !id_match (series_match || both_opus_or_draconic || both_draconic) && !id_match
end end
conflicting_weapons
end end
private private
# Conflict management methods ##
# Validates whether the grid weapon is compatible with the desired position.
# Validates whether the weapon can be added to 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 def compatible_with_position
is_extra_position = [9, 10, 11].include?(position.to_i) return unless weapon.present?
is_extra_weapon = [11, 16, 17, 28, 29, 32, 34].include?(weapon.series.to_i)
return unless is_extra_position if EXTRA_POSITIONS.include?(position.to_i) && !ALLOWED_EXTRA_SERIES.include?(weapon.series.to_i)
errors.add(:series, 'must be compatible with position')
return true if is_extra_weapon end
errors.add(:series, 'must be compatible with position')
false
end end
# Validates whether the desired weapon key can be added to the weapon ##
# Validates that the assigned weapon keys are compatible with the weapon.
#
# Iterates over each non-nil weapon key and checks compatibility using the weapon's
# `compatible_with_key?` method. An error is added for any key that is not compatible.
#
# @return [void]
def compatible_with_key def compatible_with_key
weapon_keys.each do |key| weapon_keys.each do |key|
errors.add(:weapon_keys, 'must be compatible with weapon') unless weapon.compatible_with_key?(key) errors.add(:weapon_keys, 'must be compatible with weapon') unless weapon.compatible_with_key?(key)
end end
end end
# Validates whether there is a conflict with the party ##
# Validates that there are no conflicting grid weapons in the party.
#
# Checks if the current grid weapon conflicts with any other grid weapons within the party.
# If conflicting weapons are found, an error is added.
#
# @return [void]
def no_conflicts def no_conflicts
# Check if the grid weapon conflicts with any of the other grid weapons in the party conflicting = conflicts(party)
return unless !conflicts(party).nil? && !conflicts(party).empty? errors.add(:series, 'must not conflict with existing weapons') if conflicting.any?
errors.add(:series, 'must not conflict with existing weapons')
end end
# Checks if the weapon should be a mainhand before saving the model ##
def mainhand? # Determines if the grid weapon should be marked as mainhand based on its position.
self.mainhand = position == -1 #
# If the grid weapon's position is -1, sets the `mainhand` attribute to true.
#
# @return [void]
def assign_mainhand
self.mainhand = (position == -1)
end end
end end

View file

@ -3,7 +3,7 @@
class Job < ApplicationRecord class Job < ApplicationRecord
include PgSearch::Model include PgSearch::Model
belongs_to :party belongs_to :party, optional: true
has_many :skills, class_name: 'JobSkill' has_many :skills, class_name: 'JobSkill'
multisearchable against: %i[name_en name_jp], multisearchable against: %i[name_en name_jp],

View file

@ -1,15 +1,95 @@
# frozen_string_literal: true # 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 class Party < ApplicationRecord
##### ActiveRecord Associations include GranblueEnums
# Define preview_state as an enum.
attribute :preview_state, :integer
enum :preview_state, { pending: 0, queued: 1, in_progress: 2, generated: 3, failed: 4 }
# ActiveRecord Associations
belongs_to :source_party, belongs_to :source_party,
class_name: 'Party', class_name: 'Party',
foreign_key: :source_party_id, foreign_key: :source_party_id,
optional: true optional: true
has_many :derivative_parties, has_many :remixes, -> { order(created_at: :desc) },
class_name: 'Party', class_name: 'Party',
foreign_key: :source_party_id, foreign_key: 'source_party_id',
inverse_of: :source_party, inverse_of: :source_party,
dependent: :nullify dependent: :nullify
@ -60,18 +140,21 @@ class Party < ApplicationRecord
has_many :characters, has_many :characters,
foreign_key: 'party_id', foreign_key: 'party_id',
class_name: 'GridCharacter', class_name: 'GridCharacter',
counter_cache: true,
dependent: :destroy, dependent: :destroy,
inverse_of: :party inverse_of: :party
has_many :weapons, has_many :weapons,
foreign_key: 'party_id', foreign_key: 'party_id',
class_name: 'GridWeapon', class_name: 'GridWeapon',
counter_cache: true,
dependent: :destroy, dependent: :destroy,
inverse_of: :party inverse_of: :party
has_many :summons, has_many :summons,
foreign_key: 'party_id', foreign_key: 'party_id',
class_name: 'GridSummon', class_name: 'GridSummon',
counter_cache: true,
dependent: :destroy, dependent: :destroy,
inverse_of: :party inverse_of: :party
@ -84,7 +167,10 @@ class Party < ApplicationRecord
before_create :set_shortcode before_create :set_shortcode
before_create :set_edit_key before_create :set_edit_key
##### Amoeba configuration after_commit :update_element!, on: %i[create update]
after_commit :update_extra!, on: %i[create update]
# Amoeba configuration
amoeba do amoeba do
set weapons_count: 0 set weapons_count: 0
set characters_count: 0 set characters_count: 0
@ -99,106 +185,303 @@ class Party < ApplicationRecord
include_association :summons include_association :summons
end end
##### ActiveRecord Validations # ActiveRecord Validations
validate :skills_are_unique validate :skills_are_unique
validate :guidebooks_are_unique validate :guidebooks_are_unique
attr_accessor :favorited # 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
self.enum :preview_state, { validates :clear_time, numericality: { only_integer: true }
pending: 0, validates :master_level, numericality: { only_integer: true }, allow_nil: true
queued: 1, validates :button_count, numericality: { only_integer: true }, allow_nil: true
in_progress: 2, validates :chain_count, numericality: { only_integer: true }, allow_nil: true
generated: 3, validates :turn_count, numericality: { only_integer: true }, allow_nil: true
failed: 4 validates :ultimate_mastery, numericality: { only_integer: true }, allow_nil: true
}
after_commit :schedule_preview_regeneration, if: :preview_relevant_changes? # 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)'
}
def is_favorited(user) after_commit :schedule_preview_generation, if: :should_generate_preview?
user.favorite_parties.include? self if user
end
def is_remix #########################
# Public API Methods
#########################
##
# Checks if the party is a remix of another party.
#
# @return [Boolean] true if the party is a remix; false otherwise.
def remix?
!source_party.nil? !source_party.nil?
end end
def remixes ##
Party.where(source_party_id: id) # Returns the blueprint class used for rendering the party.
end #
# @return [Class] the PartyBlueprint class.
def blueprint def blueprint
PartyBlueprint PartyBlueprint
end end
##
# Determines if the party is public.
#
# @return [Boolean] true if the party is public; false otherwise.
def public? def public?
visibility == 1 visibility == 1
end end
##
# Determines if the party is unlisted.
#
# @return [Boolean] true if the party is unlisted; false otherwise.
def unlisted? def unlisted?
visibility == 2 visibility == 2
end end
##
# Determines if the party is private.
#
# @return [Boolean] true if the party is private; false otherwise.
def private? def private?
visibility == 3 visibility == 3
end 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 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 def set_shortcode
self.shortcode = random_string self.shortcode = random_string
end 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 def set_edit_key
return if user return if user
self.edit_key = Digest::SHA1.hexdigest([Time.now, rand].join) self.edit_key ||= Digest::SHA1.hexdigest([Time.now, rand].join)
end end
##
# Generates a random alphanumeric string used for the party shortcode.
#
# @return [String] a random string of 6 characters.
def random_string def random_string
num_chars = 6 num_chars = 6
o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
(0...num_chars).map { o[rand(o.length)] }.join (0...num_chars).map { o[rand(o.length)] }.join
end 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 end

View file

@ -37,6 +37,54 @@ class Weapon < ApplicationRecord
has_many :weapon_awakenings has_many :weapon_awakenings
has_many :awakenings, through: :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 def blueprint
WeaponBlueprint WeaponBlueprint
end end
@ -51,11 +99,23 @@ class Weapon < ApplicationRecord
# Returns whether the weapon is included in the Draconic or Dark Opus series # Returns whether the weapon is included in the Draconic or Dark Opus series
def opus_or_draconic? def opus_or_draconic?
[2, 3].include?(series) [3, 27].include?(series)
end end
# Returns whether the weapon belongs to the Draconic Weapon series or the Draconic Weapon Providence series # Returns whether the weapon belongs to the Draconic Weapon series or the Draconic Weapon Providence series
def draconic_or_providence? def draconic_or_providence?
[3, 34].include?(series) [27, 40].include?(series)
end
def self.element_changeable?(series)
[4, 13, 17, 19].include?(series.to_i)
end
private
def series_slug
# Assuming series is an array, take the first value
series_number = series.first
SERIES_SLUGS[series_number]
end end
end end

View file

@ -3,12 +3,4 @@
class WeaponAwakening < ApplicationRecord class WeaponAwakening < ApplicationRecord
belongs_to :weapon belongs_to :weapon
belongs_to :awakening belongs_to :awakening
def weapon
Weapon.find(weapon_id)
end
def awakening
Awakening.find(awakening_id)
end
end end

251
app/services/dataminer.rb Normal file
View file

@ -0,0 +1,251 @@
# frozen_string_literal: true
class Dataminer
include HTTParty
BOT_UID = '39094985'
GAME_VERSION = '1741068713'
base_uri 'https://game.granbluefantasy.jp'
format :json
HEADERS = {
'Accept' => 'application/json, text/javascript, */*; q=0.01',
'Accept-Language' => 'en-US,en;q=0.9',
'Accept-Encoding' => 'gzip, deflate, br, zstd',
'Content-Type' => 'application/json',
'DNT' => '1',
'Origin' => 'https://game.granbluefantasy.jp',
'Referer' => 'https://game.granbluefantasy.jp/',
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'X-Requested-With' => 'XMLHttpRequest'
}.freeze
attr_reader :page, :cookies, :logger, :debug
def initialize(page:, access_token:, wing:, midship:, t: 'dummy', debug: false)
@page = page
@cookies = {
access_gbtk: access_token,
wing: wing,
t: t,
midship: midship
}
@debug = debug
setup_logger
end
def fetch
timestamp = Time.now.to_i * 1000
response = self.class.post(
"/#{page}?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}",
headers: HEADERS.merge(
'Cookie' => format_cookies,
'X-VERSION' => GAME_VERSION
)
)
raise AuthenticationError if auth_failed?(response)
response
end
def fetch_character(granblue_id)
timestamp = Time.now.to_i * 1000
url = "/archive/npc_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
body = {
special_token: nil,
user_id: BOT_UID,
kind_name: '0',
attribute: '0',
event_id: nil,
story_id: nil,
style: 1,
character_id: granblue_id
}
response = fetch_detail(url, body)
update_game_data('Character', granblue_id, response) if response
response
end
def fetch_weapon(granblue_id)
timestamp = Time.now.to_i * 1000
url = "/archive/weapon_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
body = {
special_token: nil,
user_id: BOT_UID,
kind_name: '0',
attribute: '0',
event_id: nil,
story_id: nil,
weapon_id: granblue_id
}
response = fetch_detail(url, body)
update_game_data('Weapon', granblue_id, response) if response
response
end
def fetch_summon(granblue_id)
timestamp = Time.now.to_i * 1000
url = "/archive/summon_detail?_=#{timestamp}&t=#{timestamp}&uid=#{BOT_UID}"
body = {
special_token: nil,
user_id: BOT_UID,
kind_name: '0',
attribute: '0',
event_id: nil,
story_id: nil,
summon_id: granblue_id
}
response = fetch_detail(url, body)
update_game_data('Summon', granblue_id, response) if response
response
end
# Public batch processing methods
def fetch_all_characters(only_missing: false)
process_all_records('Character', only_missing: only_missing)
end
def fetch_all_weapons(only_missing: false)
process_all_records('Weapon', only_missing: only_missing)
end
def fetch_all_summons(only_missing: false)
process_all_records('Summon', only_missing: only_missing)
end
private
def format_cookies
cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
end
def auth_failed?(response)
return true if response.code != 200
begin
parsed = JSON.parse(response.body)
parsed.is_a?(Hash) && parsed['auth_status'] == 'require_auth'
rescue JSON::ParserError
true
end
end
def setup_logger
@logger = ::Logger.new($stdout)
@logger.level = debug ? ::Logger::DEBUG : ::Logger::INFO
@logger.formatter = proc do |severity, _datetime, _progname, msg|
case severity
when 'DEBUG'
debug ? "#{msg}\n" : ''
else
"#{msg}\n"
end
end
# Suppress SQL logs in non-debug mode
return if debug
ActiveRecord::Base.logger.level = ::Logger::INFO if defined?(ActiveRecord::Base)
end
def fetch_detail(url, body)
logger.debug "\n=== Request Details ==="
logger.debug "URL: #{url}"
logger.debug 'Headers:'
logger.debug HEADERS.merge(
'Cookie' => format_cookies,
'X-VERSION' => GAME_VERSION
).inspect
logger.debug 'Body:'
logger.debug body.to_json
logger.debug '===================='
response = self.class.post(
url,
headers: HEADERS.merge(
'Cookie' => format_cookies,
'X-VERSION' => GAME_VERSION
),
body: body.to_json
)
logger.debug "\n=== Response Details ==="
logger.debug "Response code: #{response.code}"
logger.debug 'Response headers:'
logger.debug response.headers.inspect
logger.debug 'Raw response body:'
logger.debug response.body.inspect
begin
logger.debug 'Parsed response body (if JSON):'
logger.debug JSON.parse(response.body).inspect
rescue JSON::ParserError => e
logger.debug "Could not parse as JSON: #{e.message}"
end
logger.debug '======================'
raise AuthenticationError if auth_failed?(response)
JSON.parse(response.body)
end
def update_game_data(model_name, granblue_id, response_data)
return unless response_data.is_a?(Hash)
model = Object.const_get(model_name)
record = model.find_by(granblue_id: granblue_id)
if record
record.update(game_raw_en: response_data)
logger.debug "Updated #{model_name} #{granblue_id}"
else
logger.warn "#{model_name} with granblue_id #{granblue_id} not found in database"
end
rescue StandardError => e
logger.error "Error updating #{model_name} #{granblue_id}: #{e.message}"
end
def process_all_records(model_name, only_missing: false)
model = Object.const_get(model_name)
scope = model
scope = scope.where(game_raw_en: nil) if only_missing
total = scope.count
success_count = 0
error_count = 0
logger.info "Starting to fetch #{total} #{model_name.downcase}s#{' (missing data only)' if only_missing}..."
scope.find_each do |record|
logger.info "\nProcessing #{model_name} #{record.granblue_id} (#{success_count + error_count + 1}/#{total})"
response = case model_name
when 'Character'
fetch_character(record.granblue_id)
when 'Weapon'
fetch_weapon(record.granblue_id)
when 'Summon'
fetch_summon(record.granblue_id)
end
success_count += 1
logger.debug "Successfully processed #{model_name} #{record.granblue_id}"
sleep(1)
rescue StandardError => e
error_count += 1
logger.error "Error processing #{model_name} #{record.granblue_id}: #{e.message}"
end
logger.info "\nProcessing complete!"
logger.info "Total: #{total}"
logger.info "Successful: #{success_count}"
logger.info "Failed: #{error_count}"
end
class AuthenticationError < StandardError; end
end

View file

@ -0,0 +1,274 @@
# frozen_string_literal: true
# PartyQueryBuilder is responsible for building an ActiveRecord query for parties
# by applying a series of filters, includes, and excludes based on request parameters.
# It is used to build both the general parties query and specialized queries (like
# for a users 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 IDs 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

View file

@ -44,11 +44,25 @@ module PreviewService
end end
def add_text(image, party_name, job_icon: nil, user: nil, **options) 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_size = options.fetch(:size, '32')
font_color = options.fetch(:color, 'white') font_color = options.fetch(:color, 'white')
# Load custom font for username, for later use # Try multiple font locations
@font_path = Rails.root.join('app', 'assets', 'fonts', 'Gk-Bd.otf').to_s 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}") Rails.logger.info("Using font path: #{@font_path}")
unless File.exist?(@font_path) unless File.exist?(@font_path)
Rails.logger.error("Font file not found at: #{@font_path}") Rails.logger.error("Font file not found at: #{@font_path}")
@ -94,16 +108,18 @@ module PreviewService
end end
def draw_party_name(image, party_name, text_metrics, job_icon, font_color, font_size) 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_x = job_icon ? PADDING + 64 + 16 : PADDING
text_y = PADDING + text_metrics[:height] text_y = PADDING + text_metrics[:height]
image.combine_options do |c| image.combine_options do |c|
c.font @font_path c.gravity 'NorthWest'
c.fill font_color c.fill font_color
c.font @font_path
c.pointsize font_size c.pointsize font_size
c.draw "text #{text_x},#{text_y} '#{party_name}'" # Escape quotes and use pango markup for better text handling
c.annotate "0x0+#{text_x}+#{text_y}", party_name.gsub('"', '\"')
end end
image image
end end
@ -154,7 +170,13 @@ module PreviewService
image image
end end
def measure_text(text, font_size, font: 'Arial') 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(/'/, "'\\\\''")
# Create a temporary file for the text measurement # Create a temporary file for the text measurement
temp_file = Tempfile.new(['text_measure', '.png']) temp_file = Tempfile.new(['text_measure', '.png'])
@ -167,7 +189,7 @@ module PreviewService
'-fill', 'black', '-fill', 'black',
'-font', font, '-font', font,
'-pointsize', font_size.to_s, '-pointsize', font_size.to_s,
"label:#{text}", "label:'#{escaped_text}'", # Quote the text
temp_file.path temp_file.path
] ]
@ -181,15 +203,15 @@ module PreviewService
height: image.height, height: image.height,
width: image.width width: image.width
} }
rescue => e
Rails.logger.error "Text measurement error: #{e.message}"
# Fallback dimensions
{ height: 50, width: 200 }
ensure ensure
# Close and unlink the temporary file # Close and unlink the temporary file
temp_file.close temp_file.close
temp_file.unlink temp_file.unlink
end end
rescue => e
Rails.logger.error "Text measurement error: #{e.message}"
# Fallback dimensions
{ height: 50, width: 200 }
end end
end end
end end

View file

@ -7,6 +7,9 @@ module PreviewService
PREVIEW_EXPIRY = 30.days PREVIEW_EXPIRY = 30.days
GENERATION_TIMEOUT = 5.minutes GENERATION_TIMEOUT = 5.minutes
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews') LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
# Public Interface - Core Operations
# Initialize the party preview service # Initialize the party preview service
# #
@ -38,59 +41,59 @@ module PreviewService
return false unless should_generate? return false unless should_generate?
begin begin
Rails.logger.info("Starting preview generation for party #{@party.id}") Rails.logger.info("🖼️ Starting preview generation for party #{@party.id}")
# Update state to in_progress Rails.logger.info("🖼️ Updating party state to in_progress")
@party.update!(preview_state: :in_progress) @party.update!(preview_state: :in_progress)
set_generation_in_progress set_generation_in_progress
Rails.logger.info("Checking ImageMagick installation...") Rails.logger.info("🖼️ Checking ImageMagick installation...")
begin begin
version = `convert -version` version = `convert -version`
Rails.logger.info("ImageMagick version: #{version}") Rails.logger.info("🖼️ ImageMagick version: #{version}")
rescue => e rescue => e
Rails.logger.error("Failed to get ImageMagick version: #{e.message}") Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}")
end end
Rails.logger.info("Creating preview image...") Rails.logger.info("🖼️ Creating preview image...")
begin begin
image = create_preview_image image = create_preview_image
Rails.logger.info("Preview image created successfully") Rails.logger.info("🖼️ Preview image created successfully")
rescue => e rescue => e
Rails.logger.error("Failed to create preview image: #{e.class} - #{e.message}") Rails.logger.error("🖼️ Failed to create preview image: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n")) Rails.logger.error(e.backtrace.join("\n"))
raise e raise e
end end
Rails.logger.info("Saving preview...") Rails.logger.info("🖼️ Saving preview...")
begin begin
save_preview(image) save_preview(image)
Rails.logger.info("Preview saved successfully") Rails.logger.info("🖼️ Preview saved successfully")
rescue => e rescue => e
Rails.logger.error("Failed to save preview: #{e.class} - #{e.message}") Rails.logger.error("🖼️ Failed to save preview: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n")) Rails.logger.error(e.backtrace.join("\n"))
raise e raise e
end end
Rails.logger.info("Updating party state...") Rails.logger.info("🖼️ Updating party state...")
@party.update!( @party.update!(
preview_state: :generated, preview_state: :generated,
preview_generated_at: Time.current preview_generated_at: Time.current
) )
Rails.logger.info("Party state updated successfully") Rails.logger.info("🖼️ Party state updated successfully")
true true
rescue => e rescue => e
Rails.logger.error("Preview generation failed: #{e.class} - #{e.message}") Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}")
Rails.logger.error("Stack trace:") Rails.logger.error("🖼️ Stack trace:")
Rails.logger.error(e.backtrace.join("\n")) Rails.logger.error(e.backtrace.join("\n"))
handle_preview_generation_error(e) handle_preview_generation_error(e)
false false
ensure ensure
Rails.logger.info("Cleaning up resources...") Rails.logger.info("🖼️ Cleaning up resources...")
@image_fetcher.cleanup @image_fetcher.cleanup
clear_generation_in_progress clear_generation_in_progress
Rails.logger.info("Cleanup completed") Rails.logger.info("🖼️ Cleanup completed")
end end
end end
@ -103,6 +106,8 @@ module PreviewService
end end
# Deletes the existing preview image for the party # Deletes the existing preview image for the party
#
# @return [void]
def delete_preview def delete_preview
if Rails.env.production? if Rails.env.production?
delete_s3_preview delete_s3_preview
@ -118,47 +123,93 @@ module PreviewService
Rails.logger.error("Failed to delete preview for party #{@party.id}: #{e.message}") Rails.logger.error("Failed to delete preview for party #{@party.id}: #{e.message}")
end end
# State Management - Public
# Determines if a new preview should be generated # Determines if a new preview should be generated
# #
# @return [Boolean] True if a new preview should be generated, false otherwise # @return [Boolean] True if a new preview should be generated, false otherwise
def should_generate? def should_generate?
Rails.logger.info("Checking should_generate? conditions") Rails.logger.info("🖼️ Checking should_generate? conditions")
if generation_in_progress? unless @party.ready_for_preview?
Rails.logger.info("Generation already in progress, returning false") Rails.logger.info("🖼️ Party not ready for preview (insufficient content)")
return false return false
end end
Rails.logger.info("Preview state: #{@party.preview_state}") if generation_in_progress?
# Add 'queued' to the list of valid states for generation Rails.logger.info("🖼️ Generation already in progress, returning false")
if @party.preview_state.in?(['pending', 'failed', 'queued']) return false
Rails.logger.info("Preview state is #{@party.preview_state}, returning true")
return true
end end
if @party.preview_state == 'generated' Rails.logger.info("🖼️ Preview state: #{@party.preview_state}")
if @party.preview_generated_at < PREVIEW_EXPIRY.ago
Rails.logger.info("Preview is older than expiry time, returning true")
return true
else
Rails.logger.info("Preview is recent, returning false")
return false
end
end
Rails.logger.info("No conditions met, returning false") case @party.preview_state
false 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 end
private # 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
# Sets up the appropriate storage system based on environment # Retrieves the S3 object for the party's preview image
def setup_storage #
# Always initialize AWS service for potential image fetching # @return [Aws::S3::Types::GetObjectOutput] S3 object containing the preview image
@aws_service = AwsService.new # @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
# Create local storage paths in development # Schedules a background job to generate the preview
FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s) #
# @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 end
# Creates the preview image for the party # Creates the preview image for the party
@ -225,6 +276,21 @@ module PreviewService
image image
end 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
# Create local storage paths in development
FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s)
end
# Image Generation Pipeline
# Adds the job icon to the preview image # Adds the job icon to the preview image
# #
# @param image [MiniMagick::Image] The base image # @param image [MiniMagick::Image] The base image
@ -241,6 +307,7 @@ module PreviewService
# Organizes and draws weapons on the preview image # Organizes and draws weapons on the preview image
# #
# @param image [MiniMagick::Image] The base 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 # @return [MiniMagick::Image] The updated image with weapons
def organize_and_draw_weapons(image, grid_layout) def organize_and_draw_weapons(image, grid_layout)
mainhand_weapon = @party.weapons.find(&:mainhand) mainhand_weapon = @party.weapons.find(&:mainhand)
@ -277,9 +344,12 @@ module PreviewService
end end
end end
# Storage Operations
# Saves the preview image to the appropriate storage system # Saves the preview image to the appropriate storage system
# #
# @param image [MiniMagick::Image] The image to save # @param image [MiniMagick::Image] The image to save
# @return [void]
def save_preview(image) def save_preview(image)
if Rails.env.production? if Rails.env.production?
upload_to_s3(image) upload_to_s3(image)
@ -291,14 +361,14 @@ module PreviewService
# Uploads the preview image to S3 # Uploads the preview image to S3
# #
# @param image [MiniMagick::Image] The image to upload # @param image [MiniMagick::Image] The image to upload
# @return [void]
def upload_to_s3(image) def upload_to_s3(image)
temp_file = Tempfile.new(['preview', '.png']) temp_file = Tempfile.new(%w[preview .png])
begin begin
image.write(temp_file.path) image.write(temp_file.path)
# Use timestamped filename similar to local storage # Use fixed key without timestamp
timestamp = Time.current.strftime('%Y%m%d%H%M%S') key = "#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
key = "#{PREVIEW_FOLDER}/#{@party.shortcode}_#{timestamp}.png"
File.open(temp_file.path, 'rb') do |file| File.open(temp_file.path, 'rb') do |file|
@aws_service.s3_client.put_object( @aws_service.s3_client.put_object(
@ -310,7 +380,6 @@ module PreviewService
) )
end end
# Optionally, store this key on the party record if needed for retrieval
@party.update!(preview_s3_key: key) @party.update!(preview_s3_key: key)
ensure ensure
temp_file.close temp_file.close
@ -321,29 +390,18 @@ module PreviewService
# Saves the preview image to local storage # Saves the preview image to local storage
# #
# @param image [MiniMagick::Image] The image to save # @param image [MiniMagick::Image] The image to save
# @return [void]
def save_to_local_storage(image) 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) image.write(local_preview_path)
end end
# Generates a timestamped filename for the preview image # Path & URL Generation
#
# @return [String] Filename in format "shortcode_YYYYMMDDHHMMSS.png"
def preview_filename
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
"#{@party.shortcode}_#{timestamp}.png"
end
# Returns the full path for storing preview images locally # Generates a filename for the preview image
# #
# @return [Pathname] Full path where the preview image should be stored # @return [String] Filename for the preview image
def local_preview_path def preview_filename
LOCAL_STORAGE_PATH.join(preview_filename) "#{@party.shortcode}.png"
end end
# Returns the URL for accessing locally stored preview images # Returns the URL for accessing locally stored preview images
@ -364,6 +422,8 @@ module PreviewService
"#{PREVIEW_FOLDER}/#{@party.shortcode}.png" "#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
end end
# Preview State Management
# Checks if a preview image exists for the party # Checks if a preview image exists for the party
# #
# @return [Boolean] True if a preview exists, false otherwise # @return [Boolean] True if a preview exists, false otherwise
@ -371,10 +431,9 @@ module PreviewService
return false unless @party.preview_state == 'generated' return false unless @party.preview_state == 'generated'
if Rails.env.production? if Rails.env.production?
@aws_service.s3_client.head_object(bucket: S3_BUCKET, key: preview_key) @aws_service.file_exists?(preview_key)
true
else 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 end
rescue Aws::S3::Errors::NotFound rescue Aws::S3::Errors::NotFound
false false
@ -387,22 +446,15 @@ module PreviewService
signer = Aws::S3::Presigner.new(client: @aws_service.s3_client) signer = Aws::S3::Presigner.new(client: @aws_service.s3_client)
signer.presigned_url( signer.presigned_url(
:get_object, :get_object,
bucket: S3_BUCKET, bucket: @aws_service.bucket,
key: preview_key, key: preview_key,
expires_in: 1.hour expires_in: 1.hour.to_i
) )
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
# Marks the preview generation as in progress # Marks the preview generation as in progress
#
# @return [void]
def set_generation_in_progress def set_generation_in_progress
Rails.cache.write( Rails.cache.write(
"party_preview_generating_#{@party.id}", "party_preview_generating_#{@party.id}",
@ -412,18 +464,15 @@ module PreviewService
end end
# Clears the in-progress flag for preview generation # Clears the in-progress flag for preview generation
#
# @return [void]
def clear_generation_in_progress def clear_generation_in_progress
Rails.cache.delete("party_preview_generating_#{@party.id}") Rails.cache.delete("party_preview_generating_#{@party.id}")
end end
# Schedules a background job to generate the preview # Job Scheduling
def schedule_generation
GeneratePartyPreviewJob
.set(wait: 30.seconds)
.perform_later(@party.id)
@party.update!(preview_state: :queued) # URL Generation
end
# Provides a default preview URL based on party attributes # Provides a default preview URL based on party attributes
# #
@ -436,29 +485,48 @@ module PreviewService
end end
end end
# Cleanup Operations
# Deletes the preview from S3 # Deletes the preview from S3
#
# @return [void]
def delete_s3_preview def delete_s3_preview
@aws_service.s3_client.delete_object( @aws_service.s3_client.delete_object(
bucket: S3_BUCKET, bucket: @aws_service.bucket,
key: preview_key key: preview_key
) )
end 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 # Deletes local preview files
#
# @return [void]
def delete_local_previews def delete_local_previews
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file| Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
File.delete(file) File.delete(file)
end end
end end
# Error Handling
# Handles errors during preview generation # Handles errors during preview generation
# #
# @param error [Exception] The error that occurred # @param error [Exception] The error that occurred
# @return [void]
def handle_preview_generation_error(error) def handle_preview_generation_error(error)
Rails.logger.error("Preview generation failed for party #{@party.id}") Rails.logger.error("Preview generation failed for party #{@party.id}")
Rails.logger.error("Error: #{error.class} - #{error.message}") Rails.logger.error("Error: #{error.class} - #{error.message}")
Rails.logger.error("Backtrace:\n#{error.backtrace.join("\n")}") Rails.logger.error(error.backtrace.join("\n"))
@party.update!(preview_state: :failed)
@party.update_columns(
preview_state: 'failed',
preview_generated_at: Time.current
)
end end
end end
end end

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
module Processors
##
# BaseProcessor provides shared functionality for processing transformed deck data
# into new party records. Subclasses must implement the +process+ method.
#
# @abstract
class BaseProcessor
##
# Initializes the processor.
#
# @param party [Party] the Party record to which the component will be added.
# @param data [Object] the transformed data for this component.
# @param options [Hash] optional additional options.
def initialize(party, data, options = {})
@party = party
@data = data
@options = options
end
##
# Process the given data and create associated records.
#
# @abstract Subclasses must implement this method.
# @return [void]
def process
raise NotImplementedError, "#{self.class} must implement the process method"
end
protected
attr_reader :party, :data, :options
##
# Logs a message to Rails.logger.
#
# @param message [String] the message to log.
# @return [void]
def log(message)
Rails.logger.info "[PROCESSOR][#{self.class.name}] #{message}"
end
end
end

View file

@ -0,0 +1,89 @@
# frozen_string_literal: true
module Processors
##
# CharacterProcessor processes an array of character data and creates GridCharacter records.
#
# @example
# processor = Processors::CharacterProcessor.new(party, transformed_characters_array)
# processor.process
class CharacterProcessor < BaseProcessor
def initialize(party, data, type = :normal, options = {})
super(party, data, options)
@party = party
@data = data
end
##
# Processes character data.
#
# Iterates over each character hash in +data+ and creates a new GridCharacter record.
# Expects each character hash to include keys such as :id, :position, :uncap, etc.
#
# @return [void]
def process
unless @data.is_a?(Hash)
Rails.logger.error "[CHARACTER] Invalid data format: expected a Hash, got #{@data.class}"
return
end
unless @data.key?('deck') && @data['deck'].key?('npc')
Rails.logger.error '[CHARACTER] Missing npc data in deck JSON'
return
end
@data = @data.with_indifferent_access
characters_data = @data.dig('deck', 'npc')
grid_characters = process_characters(characters_data)
grid_characters.each do |grid_character|
begin
grid_character.save!
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "[CHARACTER] Failed to create GridCharacter: #{e.record.errors.full_messages.join(', ')}"
end
end
rescue StandardError => e
raise e
end
private
def process_characters(characters_data)
characters_data.map do |key, raw_character|
next if raw_character.nil? || raw_character['param'].nil? || raw_character['master'].nil?
position = key.to_i - 1
# Find the Character record by its granblue_id.
character_id = raw_character.dig('master', 'id')
character = Character.find_by(granblue_id: character_id)
unless character
Rails.logger.error "[CHARACTER] Character not found with id #{character_id}"
next
end
# The deck doesn't have Awakening data, so use the default
awakening = Awakening.where(slug: 'character-balanced').first
grid_character = GridCharacter.create(
party_id: @party.id,
character_id: character.id,
uncap_level: raw_character.dig('param', 'evolution').to_i,
transcendence_step: raw_character.dig('param', 'phase').to_i,
position: position,
perpetuity: raw_character.dig('param', 'has_npcaugment_constant'),
awakening: awakening
)
grid_character
end.compact
end
# Converts a value to a boolean.
def parse_boolean(val)
val.to_s.downcase == 'true'
end
end
end

View file

@ -0,0 +1,127 @@
# frozen_string_literal: true
module Processors
##
# JobProcessor is responsible for processing job data from the transformed deck data.
# It finds a Job record by the masters 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 caseinsensitive 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

View file

@ -0,0 +1,201 @@
# frozen_string_literal: true
module Processors
##
# SummonProcessor processes an array of summon data and creates GridSummon records.
# It handles different summon types based on the +type+ parameter:
# - :normal => standard summons
# - :friend => friend summon (fixed position and uncap logic)
# - :sub => sub summons (position based on order)
#
# @example
# normal_processor = SummonProcessor.new(party, summons_array, :normal, quick_summon_id)
# normal_processor.process
#
# friend_processor = SummonProcessor.new(party, [friend_summon_name], :friend)
# friend_processor.process
class SummonProcessor < BaseProcessor
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
##
# Initializes a new SummonProcessor.
#
# @param party [Party] the Party record.
# @param data [Hash] the deck hash.
# @param type [Symbol] the type of summon (:normal, :friend, or :sub).
# @param quick_summon_id [String, nil] (optional) the quick summon identifier.
# @param options [Hash] additional options.
def initialize(party, data, type = :normal, options = {})
super(party, data, options)
@party = party
@data = data
@type = type
end
##
# Processes summon data and creates GridSummon records.
#
# @return [void]
def process
unless @data.is_a?(Hash)
Rails.logger.error "[SUMMON] Invalid data format: expected a Hash, got #{@data.class}"
return
end
unless @data.key?('deck') && @data['deck'].key?('pc')
Rails.logger.error '[SUMMON] Missing npc data in deck JSON'
return
end
@data = @data.with_indifferent_access
summons_data = @data.dig('deck', 'pc', 'summons')
sub_summons_data = @data.dig('deck', 'pc', 'sub_summons')
grid_summons = process_summons(summons_data, sub: false)
friend_summon = process_friend_summon
sub_summons = process_summons(sub_summons_data, sub: true)
summons = [*grid_summons, friend_summon, *sub_summons]
summons.each do |summon|
summon.save!
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "[SUMMON] Failed to create GridSummon: #{e.record.errors.full_messages.join(', ')}"
end
end
private
attr_reader :type
##
# Processes a set of summon data and creates GridSummon records.
#
# @param summons [Hash] the summon data
# @param sub [Boolean] true if we are polling sub summons
# @return [Array<GridSummon>]
def process_summons(summons, sub: false)
internal_quick_summon_id = @data['quick_user_summon_id'].to_i if sub
summons.map do |key, raw_summon|
summon_params = raw_summon['param']
summon_id = raw_summon['master']['id']
summon = Summon.find_by(granblue_id: transform_id(summon_id))
position = if sub
key.to_i + 4
else
key.to_i == 1 ? -1 : key.to_i - 2
end
GridSummon.new({
party: @party,
summon: summon,
position: position,
main: key.to_i == 1,
friend: false,
quick_summon: summon_params['id'].to_i == internal_quick_summon_id,
uncap_level: summon_params['evolution'].to_i,
transcendence_step: level_to_transcendence(summon_params['level'].to_i),
created_at: Time.now,
updated_at: Time.now
})
end
end
##
# Processes friend summon data and creates a GridSummon record.
#
# @return [GridSummon]
def process_friend_summon
summon_name = @data.dig('deck', 'pc', 'damage_info', 'summon_name')
summon = Summon.find_by('name_en = ? OR name_jp = ?', summon_name, summon_name)
GridSummon.new({
party: @party,
summon: summon,
position: 4,
main: false,
friend: true,
quick_summon: false,
uncap_level: determine_uncap_level(summon),
transcendence_step: summon.transcendence ? 5 : 0,
created_at: Time.now,
updated_at: Time.now
})
end
##
# Determines the numeric uncap level of a given Summon
#
# @param summon [Summon] the canonical summon
# @return [Integer]
def determine_uncap_level(summon)
if summon.transcendence
6
elsif summon.ulb
5
elsif summon.flb
4
else
3
end
end
##
# Determines the uncap level for a friend summon based on its ULb and FLb flags.
#
# @param summon_data [Hash] the summon data.
# @return [Integer] the computed uncap level.
def determine_friend_uncap(summon_data)
if summon_data[:ulb]
5
elsif summon_data[:flb]
4
else
3
end
end
##
# Converts a given level, rounded down to the nearest 10,
# to its corresponding transcendence step.
#
# If level is 200, returns 0; if level is 250, returns 5.
#
# @param level [Integer] the summon's level
# @return [Integer] the transcendence step
def level_to_transcendence(level)
return 0 if level < 200
floored_level = (level / 10).floor * 10
TRANSCENDENCE_LEVELS.index(floored_level)
end
##
# Transforms 5★ Arcarum-series summon IDs into their 4★ variants,
# as that's what is stored in the database.
#
# If an unrelated ID, or the 4★ ID is passed, then returns the input.
#
# @param id [String] the ID to match
# @return [String] the resulting ID
def transform_id(id)
mapping = {
'2040315000' => '2040238000',
'2040316000' => '2040239000',
'2040314000' => '2040237000',
'2040313000' => '2040236000',
'2040321000' => '2040244000',
'2040319000' => '2040242000',
'2040317000' => '2040240000',
'2040322000' => '2040245000',
'2040318000' => '2040241000',
'2040320000' => '2040243000'
}
# If the id is a key, return the mapped value; otherwise, return the id.
mapping[id] || id
end
end
end

View file

@ -0,0 +1,395 @@
# frozen_string_literal: true
module Processors
##
# WeaponProcessor processes weapon data from a deck JSON and creates GridWeapon records.
# It follows a similar errorhandling 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 ingame AX skill IDs to our stored values.
class WeaponProcessor < BaseProcessor
TRANSCENDENCE_LEVELS = [200, 210, 220, 230, 240, 250].freeze
# Mapping from ingame 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 decks 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 weapons 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 skills 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 ingame awakening data (stored under "arousal") to our Awakening record.
#
# This method looks at the "skill" array inside the arousal data and uses the first
# awakenings 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

View file

@ -1,42 +1,42 @@
require_relative "boot" require_relative "boot"
require "rails" 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"
# Require the gems listed in Gemfile, including any gems # Include only the Rails frameworks we need
# you've limited to :test, :development, or :production. 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
Bundler.require(*Rails.groups) Bundler.require(*Rails.groups)
module HenseiApi module HenseiApi
class Application < Rails::Application class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version. # Use Rails 7.0 defaults
config.load_defaults 7.0 config.load_defaults 7.0
# Configuration for the application, engines, and railties goes here. # Configure autoloading
#
# 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.autoload_paths << Rails.root.join("lib")
config.eager_load_paths << Rails.root.join("lib") config.eager_load_paths << Rails.root.join("lib")
# Only loads a smaller set of middleware suitable for API only apps. # Configure asset handling for API mode
# Middleware like session, flash, cookies can be added back manually. config.paths["app/assets"] ||= []
# Skip views, helpers and assets when generating a new resource. 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
config.api_only = true config.api_only = true
end end
end end

View file

@ -76,4 +76,8 @@ Rails.application.configure do
# This makes it easy to tag log lines with debug information like subdomain and request id - # 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. # both very helpful in debugging multi-user production applications.
config.log_tags = [:request_id] config.log_tags = [:request_id]
config.after_initialize do
Prosopite.rails_logger = true
end
end end

View file

@ -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 # The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that # 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. # 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. # Turn false under Spring and add config.action_view.cache_template_loading = true.
config.cache_classes = true config.cache_classes = false
# Eager loading loads your whole application. When running a single test locally, # 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 # 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. # 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. # Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true config.public_file_server.enabled = true
config.public_file_server.headers = { 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. # 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.action_controller.perform_caching = false
config.cache_store = :null_store config.cache_store = :null_store

View file

@ -1,12 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class CacheFreeLogger < ActiveSupport::Logger # class CacheFreeLogger < ActiveSupport::Logger
def add(severity, message = nil, progname = nil, &block) # def add(severity, message = nil, progname = nil, &block)
return true if progname&.include? 'CACHE' # return true if progname&.include? 'CACHE'
#
super # super
end # end
end # end
#
ActiveRecord::Base.logger = CacheFreeLogger.new($stdout) ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Base.logger.level = 1 # ActiveRecord::Base.logger.level = 1

View file

@ -0,0 +1,12 @@
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

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Explicitly configure Oj to mimic JSON.
Oj::Rails.mimic_JSON

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Sentry.init do |config|
config.breadcrumbs_logger = [:active_support_logger]
config.dsn = ENV['SENTRY_DSN']
config.enable_tracing = true
end

View file

@ -1,14 +1,12 @@
# Fetch environment variables with defaults if not set redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
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"
Sidekiq.configure_server do |config| Sidekiq.configure_server do |config|
config.redis = { url: full_redis_url } 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 end
Sidekiq.configure_client do |config| Sidekiq.configure_client do |config|
config.redis = { url: full_redis_url } config.redis = { url: redis_url }
end end

68
config/newrelic.yml Normal file
View file

@ -0,0 +1,68 @@
#
# This file configures the New Relic Agent. New Relic monitors Ruby, Java,
# .NET, PHP, Python, Node, and Go applications with deep visibility and low
# overhead. For more information, visit www.newrelic.com.
#
# Generated October 28, 2022
#
# This configuration file is custom generated for NewRelic Administration
#
# For full documentation of agent configuration options, please refer to
# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration
common: &default_settings
# Required license key associated with your New Relic account.
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
# Your application name. Renaming here affects where data displays in New
# Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications
app_name: 'hensei-api'
distributed_tracing:
enabled: true
# To disable the agent regardless of other settings, uncomment the following:
# agent_enabled: false
log_file_path: logs/
# Logging level for log/newrelic_agent.log
log_level: info
application_logging:
# If `true`, all logging-related features for the agent can be enabled or disabled
# independently. If `false`, all logging-related features are disabled.
enabled: true
forwarding:
# If `true`, the agent captures log records emitted by this application.
enabled: true
# Defines the maximum number of log records to buffer in memory at a time.
max_samples_stored: 10000
metrics:
# If `true`, the agent captures metrics related to logging for this application.
enabled: true
local_decorating:
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans.
# This requires a log forwarder to send your log files to New Relic.
# This should not be used when forwarding is enabled.
enabled: false
# Environment-specific settings are in this section.
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
# If your application has other named environments, configure them here.
development:
<<: *default_settings
app_name: 'hensei-api (Development)'
test:
<<: *default_settings
# It doesn't make sense to report to New Relic from automated test runs.
monitor_mode: false
staging:
<<: *default_settings
app_name: 'hensei-api (Staging)'
production:
<<: *default_settings

View file

@ -4,20 +4,20 @@
# the maximum value specified for Puma. Default is set to 5 threads for minimum # 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. # and maximum; this matches the default thread size of Active Record.
# #
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } max_threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
threads min_threads_count, max_threads_count threads min_threads_count, max_threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000. # 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. # 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. # 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. # Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together # Workers are forked web server processes. If using threads and workers together

View file

@ -4,75 +4,79 @@ Rails.application.routes.draw do
skip_controllers :applications, :authorized_applications skip_controllers :applications, :authorized_applications
end end
namespace :api, defaults: { format: :json } do path_prefix = Rails.env.production? ? '/v1' : '/api/v1'
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]
get 'version', to: 'api#version' scope path: path_prefix, module: 'api/v1', defaults: { format: :json } do
resources :parties, only: %i[index create update destroy]
resources :users, only: %i[create update show]
resources :grid_weapons, only: %i[update destroy]
resources :grid_characters, only: %i[update destroy]
resources :grid_summons, only: %i[update destroy]
resources :weapons, only: :show
resources :characters, only: :show
resources :summons, only: :show
resources :favorites, only: [:create]
post 'import', to: 'import#create' get 'version', to: 'api#version'
get 'users/info/:id', to: 'users#info' post 'import', to: 'import#create'
post 'import/weapons', to: 'import#weapons'
post 'import/summons', to: 'import#summons'
post 'import/characters', to: 'import#characters'
get 'parties/favorites', to: 'parties#favorites' get 'users/info/:id', to: 'users#info'
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'
put 'parties/:id/jobs', to: 'jobs#update_job' get 'parties/favorites', to: 'parties#favorites'
put 'parties/:id/job_skills', to: 'jobs#update_job_skills' get 'parties/:id', to: 'parties#show'
delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill' get 'parties/:id/preview', to: 'parties#preview'
get 'parties/:id/preview_status', to: 'parties#preview_status'
post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview'
post 'parties/:id/remix', to: 'parties#remix'
post 'check/email', to: 'users#check_email' put 'parties/:id/jobs', to: 'jobs#update_job'
post 'check/username', to: 'users#check_username' put 'parties/:id/job_skills', to: 'jobs#update_job_skills'
delete 'parties/:id/job_skills', to: 'jobs#destroy_job_skill'
post 'search', to: 'search#all' post 'check/email', to: 'users#check_email'
post 'search/characters', to: 'search#characters' post 'check/username', to: 'users#check_username'
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' post 'search', to: 'search#all'
post 'search/characters', to: 'search#characters'
post 'search/weapons', to: 'search#weapons'
post 'search/summons', to: 'search#summons'
post 'search/job_skills', to: 'search#job_skills'
post 'search/guidebooks', to: 'search#guidebooks'
get 'jobs/skills', to: 'job_skills#all' get 'jobs', to: 'jobs#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 'jobs/skills', to: 'job_skills#all'
get 'jobs/:id', to: 'jobs#show'
get 'jobs/:id/skills', to: 'job_skills#job'
get 'jobs/:id/accessories', to: 'job_accessories#job'
get 'raids', to: 'raids#all' get 'guidebooks', to: 'guidebooks#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' get 'raids', to: 'raids#all'
post 'characters/resolve', to: 'grid_characters#resolve' get 'raids/groups', to: 'raids#groups'
post 'characters/update_uncap', to: 'grid_characters#update_uncap_level' get 'raids/:id', to: 'raids#show'
delete 'characters', to: 'grid_characters#destroy' get 'weapon_keys', to: 'weapon_keys#all'
post 'weapons', to: 'grid_weapons#create' post 'characters', to: 'grid_characters#create'
post 'weapons/resolve', to: 'grid_weapons#resolve' post 'characters/resolve', to: 'grid_characters#resolve'
post 'weapons/update_uncap', to: 'grid_weapons#update_uncap_level' post 'characters/update_uncap', to: 'grid_characters#update_uncap_level'
delete 'weapons', to: 'grid_weapons#destroy' delete 'characters', to: 'grid_characters#destroy'
post 'summons', to: 'grid_summons#create' post 'weapons', to: 'grid_weapons#create'
post 'summons/update_uncap', to: 'grid_summons#update_uncap_level' post 'weapons/resolve', to: 'grid_weapons#resolve'
post 'summons/update_quick_summon', to: 'grid_summons#update_quick_summon' post 'weapons/update_uncap', to: 'grid_weapons#update_uncap_level'
delete 'summons', to: 'grid_summons#destroy' delete 'weapons', to: 'grid_weapons#destroy'
delete 'favorites', to: 'favorites#destroy' post 'summons', to: 'grid_summons#create'
end post 'summons/update_uncap', to: 'grid_summons#update_uncap_level'
post 'summons/update_quick_summon', to: 'grid_summons#update_quick_summon'
delete 'summons', to: 'grid_summons#destroy'
delete 'favorites', to: 'favorites#destroy'
end end
if Rails.env.development? if Rails.env.development?

5
config/sidekiq.yml Normal file
View file

@ -0,0 +1,5 @@
:scheduler:
cleanup_party_previews:
cron: '0 0 * * *' # Daily at midnight
class: CleanupPartyPreviewsJob
queue: maintenance

View file

@ -0,0 +1,134 @@
# frozen_string_literal: true
class MigrateWeaponSeries < ActiveRecord::Migration[8.0]
def up
Weapon.transaction do
puts 'Starting weapon series migration...'
puts 'Updating Seraphic Weapons (0 -> 1)...'
Weapon.where(series: 0).update_all(new_series: 1)
puts 'Updating Grand Weapons (1 -> 2)...'
Weapon.where(series: 1).update_all(new_series: 2)
puts 'Updating Dark Opus Weapons (2 -> 3)...'
Weapon.where(series: 2).update_all(new_series: 3)
puts 'Updating Revenant Weapons (4 -> 4)...'
Weapon.where(series: 4).update_all(new_series: 4)
puts 'Updating Primal Weapons (6 -> 5)...'
Weapon.where(series: 6).update_all(new_series: 5)
puts 'Updating Beast Weapons (5, 7 -> 6)...'
Weapon.where(series: 5).update_all(new_series: 6)
Weapon.where(series: 7).update_all(new_series: 6)
puts 'Updating Regalia Weapons (8 -> 7)...'
Weapon.where(series: 8).update_all(new_series: 7)
puts 'Updating Omega Weapons (9 -> 8)...'
Weapon.where(series: 9).update_all(new_series: 8)
puts 'Updating Olden Primal Weapons (10 -> 9)...'
Weapon.where(series: 10).update_all(new_series: 9)
puts 'Updating Hollowsky Weapons (12 -> 10)...'
Weapon.where(series: 12).update_all(new_series: 10)
puts 'Updating Xeno Weapons (13 -> 11)...'
Weapon.where(series: 13).update_all(new_series: 11)
puts 'Updating Rose Weapons (15 -> 12)...'
Weapon.where(series: 15).update_all(new_series: 12)
puts 'Updating Ultima Weapons (17 -> 13)...'
Weapon.where(series: 17).update_all(new_series: 13)
puts 'Updating Bahamut Weapons (16 -> 14)...'
Weapon.where(series: 16).update_all(new_series: 14)
puts 'Updating Epic Weapons (18 -> 15)...'
Weapon.where(series: 18).update_all(new_series: 15)
puts 'Updating Cosmos Weapons (20 -> 16)...'
Weapon.where(series: 20).update_all(new_series: 16)
puts 'Updating Superlative Weapons (22 -> 17)...'
Weapon.where(series: 22).update_all(new_series: 17)
puts 'Updating Vintage Weapons (23 -> 18)...'
Weapon.where(series: 23).update_all(new_series: 18)
puts 'Updating Class Champion Weapons (24 -> 19)...'
Weapon.where(series: 24).update_all(new_series: 19)
puts 'Updating Sephira Weapons (28 -> 23)...'
Weapon.where(series: 28).update_all(new_series: 23)
puts 'Updating Astral Weapons (14 -> 26)...'
Weapon.where(series: 14).update_all(new_series: 26)
puts 'Updating Draconic Weapons (3 -> 27)...'
Weapon.where(series: 3).update_all(new_series: 27)
puts 'Updating Ancestral Weapons (21 -> 29)...'
Weapon.where(series: 21).update_all(new_series: 29)
puts 'Updating New World Foundation (29 -> 30)...'
Weapon.where(series: 29).update_all(new_series: 30)
puts 'Updating Ennead Weapons (19 -> 31)...'
Weapon.where(series: 19).update_all(new_series: 31)
puts 'Updating Militis Weapons (11 -> 32)...'
Weapon.where(series: 11).update_all(new_series: 32)
puts 'Updating Malice Weapons (26 -> 33)...'
Weapon.where(series: 26).update_all(new_series: 33)
puts 'Updating Menace Weapons (26 -> 34)...'
Weapon.where(series: 26).update_all(new_series: 34)
puts 'Updating Illustrious Weapons (31 -> 35)...'
Weapon.where(series: 31).update_all(new_series: 35)
puts 'Updating Proven Weapons (25 -> 36)...'
Weapon.where(series: 25).update_all(new_series: 36)
puts 'Updating Revans Weapons (30 -> 37)...'
Weapon.where(series: 30).update_all(new_series: 37)
puts 'Updating World Weapons (32 -> 38)...'
Weapon.where(series: 32).update_all(new_series: 38)
puts 'Updating Exo Weapons (33 -> 39)...'
Weapon.where(series: 33).update_all(new_series: 39)
puts 'Updating Draconic Weapons Providence (34 -> 40)...'
Weapon.where(series: 34).update_all(new_series: 40)
puts 'Updating Celestial Weapons (37 -> 41)...'
Weapon.where(series: 37).update_all(new_series: 41)
puts 'Updating Omega Rebirth Weapons (38 -> 42)...'
Weapon.where(series: 38).update_all(new_series: 42)
puts 'Updating Event Weapons (34 -> 98)...'
Weapon.where(series: 34).update_all(new_series: 98) # Event
puts 'Updating Gacha Weapons (36 -> 99)...'
Weapon.where(series: 36).update_all(new_series: 99) # Gacha
puts 'Migration completed successfully!'
rescue StandardError => e
puts "Error occurred during migration: #{e.message}"
puts "Backtrace: #{e.backtrace}"
raise e
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
class MigrateSeriesOnWeaponKey < ActiveRecord::Migration[8.0]
def up
WeaponKey.transaction do
puts 'Starting weapon key series migration...'
puts 'Updating Telumas (3 -> 27)...'
WeaponKey.where('? = ANY(series)', 3).update_all('series = array_replace(series, 3, 27)')
puts 'Updating Providence Telumas (34 -> 40)...'
WeaponKey.where('? = ANY(series)', 34).update_all('series = array_replace(series, 34, 40)')
puts 'Updating Gauph Keys (17 -> 13)...'
WeaponKey.where('? = ANY(series)', 17).update_all('series = array_replace(series, 17, 13)')
puts 'Updating Pendulums (2 -> 3)...'
WeaponKey.where('? = ANY(series)', 2).update_all('series = array_replace(series, 2, 3)')
puts 'Updating Chains (2 -> 3)...'
WeaponKey.where('? = ANY(series)', 2).update_all('series = array_replace(series, 2, 3)')
puts 'Updating Emblems (24 -> 19)...'
WeaponKey.where('? = ANY(series)', 24).update_all('series = array_replace(series, 24, 19)')
puts 'Migration completed successfully!'
rescue StandardError => e
puts "Error occurred during migration: #{e.message}"
puts "Backtrace: #{e.backtrace}"
raise e
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20250115094623) DataMigrate::Data.define(version: 20250218025755)

View file

@ -0,0 +1,7 @@
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

View file

@ -0,0 +1,12 @@
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

View file

@ -0,0 +1,8 @@
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

View file

@ -0,0 +1,15 @@
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

View file

@ -0,0 +1,9 @@
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

View file

@ -0,0 +1,5 @@
class RemoveUnusedIndex < ActiveRecord::Migration[8.0]
def change
remove_index :parties, :visibility
end
end

View file

@ -0,0 +1,8 @@
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

View file

@ -0,0 +1,6 @@
class MakeJobForeignKeyDeferrable < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :jobs, column: :base_job_id
add_foreign_key :jobs, :jobs, column: :base_job_id, deferrable: :deferred, initially_deferred: true
end
end

View file

@ -0,0 +1,13 @@
class RemoveForeignKeyConstraintOnJobsBaseJobId < ActiveRecord::Migration[8.0]
# Removes the self-referential foreign key constraint on jobs.base_job_id.
# This constraint was causing issues when seeding job records via CSV.
def change
# Check if the foreign key exists before removing it
if foreign_key_exists?(:jobs, column: :base_job_id)
remove_foreign_key :jobs, column: :base_job_id
Rails.logger.info 'Removed foreign key constraint on jobs.base_job_id'
else
Rails.logger.info 'No foreign key on jobs.base_job_id found'
end
end
end

View file

@ -0,0 +1,5 @@
class AddNewSeriesToWeapons < ActiveRecord::Migration[8.0]
def change
add_column :weapons, :new_series, :integer
end
end

View file

@ -0,0 +1,6 @@
class MoveNewSeriesToSeries < ActiveRecord::Migration[8.0]
def change
remove_column :weapons, :series
rename_column :weapons, :new_series, :series
end
end

View file

@ -0,0 +1,15 @@
class AddRawDataColumns < ActiveRecord::Migration[8.0]
def change
add_column :characters, :wiki_raw, :text
add_column :characters, :game_raw_en, :text
add_column :characters, :game_raw_jp, :text
add_column :summons, :wiki_raw, :text
add_column :summons, :game_raw_en, :text
add_column :summons, :game_raw_jp, :text
add_column :weapons, :wiki_raw, :text
add_column :weapons, :game_raw_en, :text
add_column :weapons, :game_raw_jp, :text
end
end

View file

@ -0,0 +1,6 @@
class AddClassicIiAndCollabToGacha < ActiveRecord::Migration[8.0]
def change
add_column :gacha, :classic_ii, :boolean, default: false
add_column :gacha, :collab, :boolean, default: false
end
end

View file

@ -10,13 +10,13 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do ActiveRecord::Schema[8.0].define(version: 2025_03_01_143956) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "uuid-ossp"
create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t| create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t|
t.string "update_type", null: false t.string "update_type", null: false
@ -67,6 +67,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.string "kamigame", default: "" t.string "kamigame", default: ""
t.string "nicknames_en", default: [], null: false, array: true t.string "nicknames_en", default: [], null: false, array: true
t.string "nicknames_jp", default: [], null: false, array: true t.string "nicknames_jp", default: [], null: false, array: true
t.text "wiki_raw"
t.text "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 t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin
end end
@ -129,6 +133,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "awakening_level", default: 1 t.integer "awakening_level", default: 1
t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id" t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id"
t.index ["character_id"], name: "index_grid_characters_on_character_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" t.index ["party_id"], name: "index_grid_characters_on_party_id"
end end
@ -143,6 +148,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "transcendence_step", default: 0, null: false t.integer "transcendence_step", default: 0, null: false
t.boolean "quick_summon", default: 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 ["party_id"], name: "index_grid_summons_on_party_id"
t.index ["summon_id"], name: "index_grid_summons_on_summon_id" t.index ["summon_id"], name: "index_grid_summons_on_summon_id"
end end
@ -168,6 +174,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "transcendence_step", default: 0 t.integer "transcendence_step", default: 0
t.string "weapon_key4_id" t.string "weapon_key4_id"
t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_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 ["party_id"], name: "index_grid_weapons_on_party_id"
t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_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" t.index ["weapon_key1_id"], name: "index_grid_weapons_on_weapon_key1_id"
@ -304,18 +311,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.datetime "preview_generated_at" t.datetime "preview_generated_at"
t.string "preview_s3_key" t.string "preview_s3_key"
t.index ["accessory_id"], name: "index_parties_on_accessory_id" 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 ["guidebook1_id"], name: "index_parties_on_guidebook1_id"
t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id" t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id"
t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id" t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id"
t.index ["job_id"], name: "index_parties_on_job_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_generated_at"], name: "index_parties_on_preview_generated_at"
t.index ["preview_state"], name: "index_parties_on_preview_state" 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 ["skill0_id"], name: "index_parties_on_skill0_id"
t.index ["skill1_id"], name: "index_parties_on_skill1_id" t.index ["skill1_id"], name: "index_parties_on_skill1_id"
t.index ["skill2_id"], name: "index_parties_on_skill2_id" t.index ["skill2_id"], name: "index_parties_on_skill2_id"
t.index ["skill3_id"], name: "index_parties_on_skill3_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 ["source_party_id"], name: "index_parties_on_source_party_id"
t.index ["user_id"], name: "index_parties_on_user_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 end
create_table "pg_search_documents", force: :cascade do |t| create_table "pg_search_documents", force: :cascade do |t|
@ -331,6 +344,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable" t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
end 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| create_table "raid_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name_en", null: false t.string "name_en", null: false
t.string "name_jp", null: false t.string "name_jp", null: false
@ -349,7 +373,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "element" t.integer "element"
t.string "slug" t.string "slug"
t.uuid "group_id" t.uuid "group_id"
t.index ["group_id"], name: "index_raids_on_group_id"
end end
create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -400,6 +423,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.date "transcendence_date" t.date "transcendence_date"
t.string "nicknames_en", default: [], null: false, array: true t.string "nicknames_en", default: [], null: false, array: true
t.string "nicknames_jp", default: [], null: false, array: true t.string "nicknames_jp", default: [], null: false, array: true
t.text "wiki_raw"
t.text "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 t.index ["name_en"], name: "index_summons_on_name_en", opclass: :gin_trgm_ops, using: :gin
end end
@ -444,7 +471,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "rarity" t.integer "rarity"
t.integer "element" t.integer "element"
t.integer "proficiency" t.integer "proficiency"
t.integer "series", default: -1, null: false
t.boolean "flb", default: false, null: false t.boolean "flb", default: false, null: false
t.boolean "ulb", default: false, null: false t.boolean "ulb", default: false, null: false
t.integer "max_level", default: 100, null: false t.integer "max_level", default: 100, null: false
@ -474,6 +500,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.boolean "transcendence", default: false t.boolean "transcendence", default: false
t.date "transcendence_date" t.date "transcendence_date"
t.string "recruits" 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 ["name_en"], name: "index_weapons_on_name_en", opclass: :gin_trgm_ops, using: :gin
t.index ["recruits"], name: "index_weapons_on_recruits" t.index ["recruits"], name: "index_weapons_on_recruits"
end end
@ -489,7 +521,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
add_foreign_key "grid_weapons", "parties" add_foreign_key "grid_weapons", "parties"
add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id" add_foreign_key "grid_weapons", "weapon_keys", column: "weapon_key3_id"
add_foreign_key "grid_weapons", "weapons" 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_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
add_foreign_key "parties", "guidebooks", column: "guidebook1_id" add_foreign_key "parties", "guidebooks", column: "guidebook1_id"
@ -501,9 +532,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
add_foreign_key "parties", "job_skills", column: "skill2_id" add_foreign_key "parties", "job_skills", column: "skill2_id"
add_foreign_key "parties", "job_skills", column: "skill3_id" add_foreign_key "parties", "job_skills", column: "skill3_id"
add_foreign_key "parties", "jobs" add_foreign_key "parties", "jobs"
add_foreign_key "parties", "parties", column: "source_party_id"
add_foreign_key "parties", "raids" add_foreign_key "parties", "raids"
add_foreign_key "parties", "users" add_foreign_key "parties", "users"
add_foreign_key "raids", "raid_groups", column: "group_id" add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey"
add_foreign_key "weapon_awakenings", "awakenings" add_foreign_key "weapon_awakenings", "awakenings"
add_foreign_key "weapon_awakenings", "weapons" add_foreign_key "weapon_awakenings", "weapons"
end end

98
db/seed/canonical.rb Normal file
View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
# canonical.rb - Loads canonical seed data from CSV files into the database.
#
# This file is used to load canonical data for various models from CSV files
# located in db/seed/test. For models that reference other models by fixed IDs
# (e.g. Job, Guidebook, etc.), use the `use_id: true` option to preserve the CSV
# provided IDs (so that inter-model references remain correct).
#
# @example
# load_csv_for(Character, 'characters_test.csv', :granblue_id)
#
# # For objects that need to preserve the CSV "id" column:
# load_csv_for(Job, 'jobs_test.csv', :granblue_id, use_id: true)
#
require 'csv'
##
# Processes specified columns in an attributes hash to booleans.
#
# @param attrs [Hash] The attributes hash.
# @param columns [Array<Symbol>] The list of columns to cast to boolean.
def process_booleans(attrs, columns)
columns.each do |col|
next unless attrs.key?(col) && attrs[col].present?
# Use ActiveModel::Type::Boolean to cast the value.
attrs[col] = ActiveModel::Type::Boolean.new.cast(attrs[col])
end
end
##
# Processes specified columns in an attributes hash to dates.
#
# @param attrs [Hash] The attributes hash.
# @param columns [Array<Symbol>] The list of columns to parse as dates.
def process_dates(attrs, columns)
columns.each do |col|
next unless attrs.key?(col) && attrs[col].present?
# Parse the date, or assign nil if parsing fails.
attrs[col] = Date.parse(attrs[col]) rescue nil
end
end
##
# Loads CSV data for the given model class.
#
# Reads a CSV file from the db/seed/test directory and uses the given unique_key
# to determine whether a record already exists. If the record exists, its attributes
# are not overwritten; otherwise, a new record is created.
#
# @param model_class [Class] The ActiveRecord model class to load data for.
# @param csv_filename [String] The CSV filename (located in db/seed/test).
# @param unique_key [Symbol] The attribute used to uniquely identify a record (default: :granblue_id).
# @param use_id [Boolean] If true, preserves the CSV id field instead of removing it (default: false).
#
# @return [void]
def load_csv_for(model_class, csv_filename, unique_key = :granblue_id, use_id: false)
csv_file = Rails.root.join('db', 'seed', 'test', csv_filename)
# puts "Loading #{model_class.name} data from #{csv_file}..."
CSV.foreach(csv_file, headers: true) do |row|
# Convert CSV row to a hash with symbolized keys.
attrs = row.to_hash.symbolize_keys
# Process known boolean columns.
process_booleans(attrs, %i[flb ulb subaura limit transcendence])
# Process known date columns. Extend this list as needed.
process_dates(attrs, %i[release_date flb_date ulb_date transcendence_date created_at])
# Clean up attribute values: trim whitespace and convert empty strings to nil.
attrs.each { |k, v| attrs[k] = nil if v.is_a?(String) && v.strip.empty? }
# Remove the :id attribute unless we want to preserve it (for fixed canonical IDs).
attrs.except!(:id) unless use_id
# Find or create the record based on the unique key.
record = model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
# Assign all attributes except the unique_key.
r.assign_attributes(attrs.except(unique_key))
end
# puts "Loaded #{model_class.name}: #{record.public_send(unique_key)}"
end
end
# Load canonical data for core models.
load_csv_for(Awakening, 'awakenings_test.csv', :id, use_id: true)
load_csv_for(Summon, 'summons_test.csv', :id, use_id: true)
load_csv_for(Weapon, 'weapons_test.csv', :id, use_id: true)
load_csv_for(Character, 'characters_test.csv', :id, use_id: true)
# Load additional canonical data that require preserving the provided IDs.
load_csv_for(Job, 'jobs_test.csv', :id, use_id: true)
load_csv_for(Guidebook, 'guidebooks_test.csv', :id, use_id: true)
load_csv_for(JobAccessory, 'job_accessories_test.csv', :id, use_id: true)
load_csv_for(JobSkill, 'job_skills_test.csv', :id, use_id: true)
load_csv_for(WeaponAwakening, 'weapon_awakenings_test.csv', :id, use_id: true)
load_csv_for(WeaponKey, 'weapon_keys_test.csv', :id, use_id: true)

View file

@ -0,0 +1,11 @@
"id","name_en","name_jp","slug","object_type","order"
"6e233877-8cda-4c8f-a091-3db6f68749e2","Attack","攻撃","character-atk","Character",1
"b1847c82-ece0-4d7a-8af1-c7868d90f34a","Balanced","バランス","character-balanced","Character",0
"c95441de-f949-4a62-b02b-101aa2e0a638","Defense","防御","character-def","Character",2
"e36b0573-79c3-4dd2-9524-c95def4bbb1a","Multiattack","連続攻撃","character-multi","Character",3
"d691a61c-dc7e-4d92-a8e6-98c04608353c","Attack","攻撃","weapon-atk","Weapon",1
"a60b8356-ec37-4f8b-a188-a3d48803ac76","C.A.","奥義","weapon-ca","Weapon",4
"969d37db-5f14-4d1a-bef4-59ba5a016674","Defense","防御","weapon-def","Weapon",2
"26a47007-8886-476a-b6c0-b56c8fcdb09f","Healing","回復","weapon-heal","Weapon",5
"18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7","Skill DMG","アビダメ","weapon-skill","Weapon",6
"275c9de5-db1e-4c66-8210-660505fd1af4","Special","特殊","weapon-special","Weapon",3
1 id name_en name_jp slug object_type order
2 6e233877-8cda-4c8f-a091-3db6f68749e2 Attack 攻撃 character-atk Character 1
3 b1847c82-ece0-4d7a-8af1-c7868d90f34a Balanced バランス character-balanced Character 0
4 c95441de-f949-4a62-b02b-101aa2e0a638 Defense 防御 character-def Character 2
5 e36b0573-79c3-4dd2-9524-c95def4bbb1a Multiattack 連続攻撃 character-multi Character 3
6 d691a61c-dc7e-4d92-a8e6-98c04608353c Attack 攻撃 weapon-atk Weapon 1
7 a60b8356-ec37-4f8b-a188-a3d48803ac76 C.A. 奥義 weapon-ca Weapon 4
8 969d37db-5f14-4d1a-bef4-59ba5a016674 Defense 防御 weapon-def Weapon 2
9 26a47007-8886-476a-b6c0-b56c8fcdb09f Healing 回復 weapon-heal Weapon 5
10 18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7 Skill DMG アビダメ weapon-skill Weapon 6
11 275c9de5-db1e-4c66-8210-660505fd1af4 Special 特殊 weapon-special Weapon 3

View file

@ -0,0 +1,17 @@
"id","name_en","name_jp","granblue_id","release_date","wiki_en","wiki_ja","rarity","element","proficiency1","proficiency2","gender","race1","race2","flb","min_hp","max_hp","max_hp_flb","min_atk","max_atk","max_atk_flb","base_da","base_ta","ougi_ratio","ougi_ratio_flb","special","ulb","max_hp_ulb","max_atk_ulb","character_id","nicknames_en","nicknames_jp","flb_date","ulb_date","gamewith","kamigame"
"9ad10c6f-83cd-4de3-a1ec-829efe0ac83b","Rosamia (SSR)","ロザミア(SSR)","3040087000","2016-06-30","Rosamia (SSR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SSR%29",3,6,1,,2,1,,FALSE,300,1600,,300,1600,,10,5,4.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"33985","SSR/ロザミア"
"afd282c7-ba4d-4213-b039-4ae7b71ef26e","Rosamia","ロザミア","3020018000","2014-03-10","Rosamia","%A5%ED%A5%B6%A5%DF%A5%A2%20%28R%29",1,6,1,,2,1,,FALSE,210,840,,950,3800,,,,,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21155","R/ロザミアR"
"d5fb1b79-483f-44cf-8437-92ce31f5f2b2","Rosamia (SR)","ロザミア(SR)","3030049000","2014-12-31","Rosamia (SR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SR%29",2,6,1,,2,1,,FALSE,260,1300,,260,1300,,7,3,3.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21090","SR/ロザミア"
"24bf1c09-509f-4db1-b953-b95ebcc69fb9","Seofon","シエテ","3040036000","2015-04-16","Seofon","%A5%B7%A5%A8%A5%C6%20%28SSR%29",3,1,1,,1,1,,TRUE,237,1477,1777,1777,10777,12777,10,5,4.5,5,FALSE,TRUE,14777,1977,"{4007}","{siete}","{}","2017-03-20","2021-06-29","21117","SSR/シエテ"
"b1eae4fe-e35c-44da-aa4f-7ca1c3e5863f","Zeta","ゼタ","3040028000","2014-12-31","Zeta","%A5%BC%A5%BF%20%28SSR%29",3,2,4,,2,1,,TRUE,240,1280,1520,240,1280,1520,10,5,5,,FALSE,FALSE,,,"{3024}","{}","{}","2020-01-28",,"21290","SSR/ゼタ"
"03b0bee9-e75b-4d5f-9ffc-0ad8cc2df7f0","Percival (Grand)","パーシヴァル(リミテッドver)","3040425000","2022-09-30","Percival (Grand)","%A5%D1%A1%BC%A5%B7%A5%F4%A5%A1%A5%EB%20%28SSR%29%A5%EA%A5%DF%A5%C6%A5%C3%A5%C9%A5%D0%A1%BC%A5%B8%A5%E7%A5%F3",3,2,1,,1,1,,FALSE,230,1340,,2010,10120,,,,,,FALSE,FALSE,,,"{3042}","{}","{}",,,"366171","SSR/リミテッドパーシヴァル"
"c96d4ba1-8a99-49fd-acde-d8eec45f6161","Fenie (Grand)","フェニー(リミテッドver)","3040519000","2024-03-15","Fenie (Grand)","",3,2,6,,2,0,,FALSE,294,1724,,1380,6580,,,,,,FALSE,FALSE,,,"{3246}","{}","{}",,,"",""
"3e7c163c-c92f-404e-8ec1-fe73bce6d6c3","Alanaan","アラナン","3040167000","2019-03-10","Alanaan","%A5%A2%A5%E9%A5%CA%A5%F3%20%28SSR%29",3,2,6,,1,2,,TRUE,219,1319,1519,1605,9705,11305,10,5,4.5,,FALSE,FALSE,,,"{3106}","{}","{}",,,"144742","SSR/アラナン"
"427f3e8a-8148-4b76-8982-f6a625a0f1e6","Zeta (Grand)","ゼタ(リミテッドver)","3040499000","2023-12-28","Zeta (Grand)","",3,2,4,,2,1,,FALSE,,1100,,,10500,,,,,,FALSE,FALSE,,,"{3024}","{}","{}",,,"",""
"437ddfde-7c39-469f-b75e-102f30595880","Fraux","フラウ","3040161000","2019-03-10","Fraux","%A5%D5%A5%E9%A5%A6%20%28SSR%29",3,2,7,,2,2,,TRUE,215,1315,,1608,9808,,10,5,4.5,,FALSE,FALSE,,,"{3100}","{}","{}","2023-08-16",,"144749","SSR/フラウ"
"76fe3ab2-e192-42f5-b063-920a2e406fb4","Michael","ミカエル","3040440000","2022-12-31","Michael","%A5%DF%A5%AB%A5%A8%A5%EB%20%28SSR%29",3,2,1,,2,5,,FALSE,240,1256,,2200,11320,,,,,,FALSE,FALSE,,,"{3217}","{}","{}",,,"381021","SSR/ミカエル"
"336f11a7-35b7-4a69-8041-c747a0c10b53","Fediel","フェディエル(リミテッドver)","3040376000","2021-12-31","Fediel","%A5%D5%A5%A7%A5%C7%A5%A3%A5%A8%A5%EB%20%28SSR%29",3,5,1,7,0,3,,FALSE,224,1200,,2015,10720,,,,,,FALSE,FALSE,,,"{3191}","{}","{}",,,"311659","SSR/フェディエル"
"180527e3-58ad-4e90-91ed-c70fa91798f7","Tikoh","ティコ","3040337000","2021-05-18","Tikoh","%A5%C6%A5%A3%A5%B3%20%28SSR%29",3,6,6,,2,2,,FALSE,367,1794,,730,4710,,,,,,FALSE,FALSE,,,"{3179}","{}","{}",,,"277461","SSR/ティコ"
"83ef5ef3-5180-465b-981e-6a121894aaec","Halluel and Malluel","ハールート・マールート(リミテッドver)","3040443000","2023-01-19","Halluel and Malluel","%A5%CF%A1%BC%A5%EB%A1%BC%A5%C8%A1%A6%A5%DE%A1%BC%A5%EB%A1%BC%A5%C8%20%28SSR%29",3,5,2,,2,5,,FALSE,290,800,,1500,4400,,,,,,FALSE,FALSE,,,"{3138}","{}","{}",,,"384939","SSR/リミテッドハールートマールート"
"8dbebe0d-12ed-4334-b3d7-8f516b8b2e23","Lich","リッチリミテッドver","3040357000","2021-09-15","Lich","%A5%EA%A5%C3%A5%C1%20%28SSR%29",3,5,6,,2,5,,FALSE,260,1300,,1550,8600,,,,,,FALSE,FALSE,,,"{3184}","{}","{}",,,"294327","SSR/リッチ"
"e9bb4639-d4f2-4299-b3ed-d396760a30eb","Nier","ニーア","3040169000","2019-03-10","Nier","%A5%CB%A1%BC%A5%A2%20%28SSR%29",3,5,3,2,2,2,,TRUE,200,1313,1513,1476,8906,10306,7,3,4.5,,FALSE,FALSE,,,"{3108}","{}","{}","2023-06-07",,"144747","SSR/ニーア"
1 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
2 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/ロザミア
3 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)
4 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/ロザミア
5 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/シエテ
6 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/ゼタ
7 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/リミテッドパーシヴァル
8 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} {} {}
9 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/アラナン
10 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} {} {}
11 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/フラウ
12 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/ミカエル
13 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/フェディエル
14 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/ティコ
15 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/リミテッドハールートマールート
16 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/リッチ
17 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/ニーア

View file

@ -0,0 +1,9 @@
"id","granblue_id","name_en","name_jp","description_en","description_jp","created_at"
"3905ccba-fc56-44ef-890d-94b858ded339","6","Acuity's Guidebook","鋭撃の導本","Grants Stamina and more","渾身効果などを得られる","2023-04-17 23:19:19.425728"
"4285b593-31ff-45e3-bd96-55c972199753","8","Insight's Guidebook","啓示の導本","Improves debuff resistance and success rate","弱体効果に強くなる","2023-04-17 23:19:19.425728"
"794b2e5f-9eec-4d27-93ee-c7971eb25862","16","Shockwave's Guidebook","激震の導本","Greatly improves normal attacks","通常攻撃を大幅に強化する効果が得られる","2023-04-17 23:19:19.425728"
"8453e4e8-1c86-4a92-a164-41762e5f5e49","5","Tenebrosity's Guidebook","暗夜の導本","Improves multiattack rate and more","連続攻撃確率アップなどの効果が得られる","2023-04-17 23:19:19.425728"
"a35af3f7-3e37-46f5-9fef-615819b8492b","4","Valor's Guidebook","勇気の導本","Grant Bonus DMG effect and more","追撃などの効果が得られる","2023-04-17 23:19:19.425728"
"a9313de5-092c-4f72-a5bb-e7f09f550961","7","Fortitude's Guidebook","守護者の導本","Greatly improves survivability","耐久効果を多く得られる","2023-04-17 23:19:19.425728"
"af73e2ad-aae4-47dc-8f4e-c9c0d4225a84","15","Sanctum's Guidebook","聖域の導本","Greatly improves battle longevity","継戦能力が大きく高まる効果を得られる","2023-04-17 23:19:19.425728"
"bbd6368d-567c-4d23-aa75-c2fe2c6818ff","10","Adept's Guidebook","魔刃の導本","Improves skills","アビリティを強化する効果が得られる","2023-04-17 23:19:19.425728"
1 id granblue_id name_en name_jp description_en description_jp created_at
2 3905ccba-fc56-44ef-890d-94b858ded339 6 Acuity's Guidebook 鋭撃の導本 Grants Stamina and more 渾身効果などを得られる 2023-04-17 23:19:19.425728
3 4285b593-31ff-45e3-bd96-55c972199753 8 Insight's Guidebook 啓示の導本 Improves debuff resistance and success rate 弱体効果に強くなる 2023-04-17 23:19:19.425728
4 794b2e5f-9eec-4d27-93ee-c7971eb25862 16 Shockwave's Guidebook 激震の導本 Greatly improves normal attacks 通常攻撃を大幅に強化する効果が得られる 2023-04-17 23:19:19.425728
5 8453e4e8-1c86-4a92-a164-41762e5f5e49 5 Tenebrosity's Guidebook 暗夜の導本 Improves multiattack rate and more 連続攻撃確率アップなどの効果が得られる 2023-04-17 23:19:19.425728
6 a35af3f7-3e37-46f5-9fef-615819b8492b 4 Valor's Guidebook 勇気の導本 Grant Bonus DMG effect and more 追撃などの効果が得られる 2023-04-17 23:19:19.425728
7 a9313de5-092c-4f72-a5bb-e7f09f550961 7 Fortitude's Guidebook 守護者の導本 Greatly improves survivability 耐久効果を多く得られる 2023-04-17 23:19:19.425728
8 af73e2ad-aae4-47dc-8f4e-c9c0d4225a84 15 Sanctum's Guidebook 聖域の導本 Greatly improves battle longevity 継戦能力が大きく高まる効果を得られる 2023-04-17 23:19:19.425728
9 bbd6368d-567c-4d23-aa75-c2fe2c6818ff 10 Adept's Guidebook 魔刃の導本 Improves skills アビリティを強化する効果が得られる 2023-04-17 23:19:19.425728

View file

@ -0,0 +1,9 @@
"id","job_id","name_en","name_jp","granblue_id","rarity","release_date","accessory_type"
"32295cc2-c1ed-4e1b-9273-baa79262bf66","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Bahamut Mino","バハムート・ミニステル","5",0,"2022-01-25",2
"32786311-6d8f-4b3b-99f7-7dd53343a0f3","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Yggdrasil Mino","ユグドラシル・ミニステル","4",0,"2022-01-25",2
"824c06c8-0d4c-485a-9cc6-3e72e58a5588","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Mini Mimic","ミニック","7",0,"2022-01-25",2
"8490d389-3f41-47f5-9ae5-c5bcf7f39965","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Lu Woh Mino","ル・オー・ミニステル","6",0,"2022-01-25",2
"a2cf6934-deab-4082-8eb8-6ec3c9c0d53e","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Ouroboros Mino","ウロボロス・ミニステル","8",3,"2022-09-06",2
"aee2ee5b-7847-45af-aab4-ba210a199bcb","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Leviathan Mino","リヴァイアサン・ミニステル","3",0,"2022-01-25",2
"af013d1b-cc40-43ec-9d34-3a0ea0592e52","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Burlona","ブルロネ","1",0,"2022-01-25",2
"dce5f041-b709-4cf4-aa71-bec44727d6ce","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Schalk","シャルク","2",0,"2022-01-25",2
1 id job_id name_en name_jp granblue_id rarity release_date accessory_type
2 32295cc2-c1ed-4e1b-9273-baa79262bf66 a5d6fca3-5649-4e12-a6db-5fcec49150ee Bahamut Mino バハムート・ミニステル 5 0 2022-01-25 2
3 32786311-6d8f-4b3b-99f7-7dd53343a0f3 a5d6fca3-5649-4e12-a6db-5fcec49150ee Yggdrasil Mino ユグドラシル・ミニステル 4 0 2022-01-25 2
4 824c06c8-0d4c-485a-9cc6-3e72e58a5588 a5d6fca3-5649-4e12-a6db-5fcec49150ee Mini Mimic ミニック 7 0 2022-01-25 2
5 8490d389-3f41-47f5-9ae5-c5bcf7f39965 a5d6fca3-5649-4e12-a6db-5fcec49150ee Lu Woh Mino ル・オー・ミニステル 6 0 2022-01-25 2
6 a2cf6934-deab-4082-8eb8-6ec3c9c0d53e a5d6fca3-5649-4e12-a6db-5fcec49150ee Ouroboros Mino ウロボロス・ミニステル 8 3 2022-09-06 2
7 aee2ee5b-7847-45af-aab4-ba210a199bcb a5d6fca3-5649-4e12-a6db-5fcec49150ee Leviathan Mino リヴァイアサン・ミニステル 3 0 2022-01-25 2
8 af013d1b-cc40-43ec-9d34-3a0ea0592e52 a5d6fca3-5649-4e12-a6db-5fcec49150ee Burlona ブルロネ 1 0 2022-01-25 2
9 dce5f041-b709-4cf4-aa71-bec44727d6ce a5d6fca3-5649-4e12-a6db-5fcec49150ee Schalk シャルク 2 0 2022-01-25 2

View file

@ -0,0 +1,18 @@
"id","job_id","name_en","name_jp","slug","color","main","sub","emp","order","base"
"589d1718-887f-4837-9a7b-93e9ce33bbf3","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Aether Siphon","エーテルサクション","aether-siphon",2,TRUE,FALSE,FALSE,0,FALSE
"b0fa1cbd-1761-49f7-b250-d601a98fddac","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Wild Magica","ワイルドマギカ","wild-magica",2,FALSE,FALSE,TRUE,1,FALSE
"0cdd20ec-5869-4bff-8016-35a4a48e897a","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Secret Triad","シークレットトライアド","secret-triad",0,FALSE,FALSE,TRUE,2,FALSE
"a42211a5-e7fd-4cdb-80a9-f2fb3ccce7f2","a5d6fca3-5649-4e12-a6db-5fcec49150ee","Overtrance","オーバートランス","overtrance",0,FALSE,FALSE,TRUE,3,FALSE
"b0a50aec-6f88-4a0f-900a-c26e84fd09c6","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Full Arsenal III","ウェポンバーストIII","full-arsenal-iii",0,TRUE,FALSE,FALSE,0,FALSE
"fdfdee6d-6ead-4504-9add-a04776546b15","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Armor Break II","レイジIV","armor-break-ii",2,FALSE,FALSE,TRUE,1,FALSE
"4df00bf2-aab1-4bd4-a399-fcad942c7daf","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Rage IV","アーマーブレイクII","rage-iv",0,FALSE,FALSE,TRUE,2,FALSE
"e705ef94-4d70-4e24-b505-4a1e8b0038f0","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Rampage II","ランページII","rampage-ii",0,FALSE,FALSE,TRUE,3,FALSE
"30df2315-457a-414c-9eef-3980b72b17c2","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Ulfhedinn","ウールヴヘジン","ulfhedinn",0,FALSE,FALSE,TRUE,4,FALSE
"3b862283-c2b0-42ab-abf8-83f7b71d5fb5","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Ferocious Roar","フェロシティロアー","ferocious-roar",0,FALSE,FALSE,TRUE,5,FALSE
"a1491902-838f-4e7d-8a4a-572a5653537f","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Beast Fang","ビーストファング","beast-fang",2,FALSE,FALSE,TRUE,6,FALSE
"0d2987b1-2322-48b6-a071-e6b60699889b","1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Bloodzerker","狂戦の血","bloodzerker",0,FALSE,FALSE,TRUE,7,FALSE
"e76fbe2a-4dc7-4c29-9db7-7feee06559fb","43652444-64bb-4938-85d7-aafdfc503d66","Miserable Mist","ミゼラブルミスト","miserable-mist",1,TRUE,TRUE,FALSE,1,FALSE
"37218a55-3335-4457-98c3-4d8367af3d7c","d27a4f29-5f0b-4bc6-b75a-1bd187e1a529","Splitting Spirit","他心陣","splitting-spirit",0,TRUE,TRUE,FALSE,2,FALSE
"61e9a862-41f1-477a-9131-72b366c359be","2abbab55-5bf7-49f8-9ed6-1fe8a3991cca","Clarity","クリアオール","clarity",3,TRUE,TRUE,FALSE,1,FALSE
"4a00259a-9e2b-4239-bca2-2afdc2f52be7","c128944b-cc79-45b4-bfed-17c8b68db612","Dispel","ディスペル","dispel",1,TRUE,TRUE,FALSE,1,FALSE
"67a126d1-5515-492f-aeaf-7f88b25e2e26","667bf041-61c9-4568-bdd3-ce6e43f40603","Dark Haze","ブラックヘイズ","dark-haze",1,FALSE,FALSE,TRUE,1,FALSE
1 id job_id name_en name_jp slug color main sub emp order base
2 589d1718-887f-4837-9a7b-93e9ce33bbf3 a5d6fca3-5649-4e12-a6db-5fcec49150ee Aether Siphon エーテルサクション aether-siphon 2 TRUE FALSE FALSE 0 FALSE
3 b0fa1cbd-1761-49f7-b250-d601a98fddac a5d6fca3-5649-4e12-a6db-5fcec49150ee Wild Magica ワイルドマギカ wild-magica 2 FALSE FALSE TRUE 1 FALSE
4 0cdd20ec-5869-4bff-8016-35a4a48e897a a5d6fca3-5649-4e12-a6db-5fcec49150ee Secret Triad シークレットトライアド secret-triad 0 FALSE FALSE TRUE 2 FALSE
5 a42211a5-e7fd-4cdb-80a9-f2fb3ccce7f2 a5d6fca3-5649-4e12-a6db-5fcec49150ee Overtrance オーバートランス overtrance 0 FALSE FALSE TRUE 3 FALSE
6 b0a50aec-6f88-4a0f-900a-c26e84fd09c6 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Full Arsenal III ウェポンバーストIII full-arsenal-iii 0 TRUE FALSE FALSE 0 FALSE
7 fdfdee6d-6ead-4504-9add-a04776546b15 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Armor Break II レイジIV armor-break-ii 2 FALSE FALSE TRUE 1 FALSE
8 4df00bf2-aab1-4bd4-a399-fcad942c7daf 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Rage IV アーマーブレイクII rage-iv 0 FALSE FALSE TRUE 2 FALSE
9 e705ef94-4d70-4e24-b505-4a1e8b0038f0 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Rampage II ランページII rampage-ii 0 FALSE FALSE TRUE 3 FALSE
10 30df2315-457a-414c-9eef-3980b72b17c2 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Ulfhedinn ウールヴヘジン ulfhedinn 0 FALSE FALSE TRUE 4 FALSE
11 3b862283-c2b0-42ab-abf8-83f7b71d5fb5 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Ferocious Roar フェロシティロアー ferocious-roar 0 FALSE FALSE TRUE 5 FALSE
12 a1491902-838f-4e7d-8a4a-572a5653537f 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Beast Fang ビーストファング beast-fang 2 FALSE FALSE TRUE 6 FALSE
13 0d2987b1-2322-48b6-a071-e6b60699889b 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Bloodzerker 狂戦の血 bloodzerker 0 FALSE FALSE TRUE 7 FALSE
14 e76fbe2a-4dc7-4c29-9db7-7feee06559fb 43652444-64bb-4938-85d7-aafdfc503d66 Miserable Mist ミゼラブルミスト miserable-mist 1 TRUE TRUE FALSE 1 FALSE
15 37218a55-3335-4457-98c3-4d8367af3d7c d27a4f29-5f0b-4bc6-b75a-1bd187e1a529 Splitting Spirit 他心陣 splitting-spirit 0 TRUE TRUE FALSE 2 FALSE
16 61e9a862-41f1-477a-9131-72b366c359be 2abbab55-5bf7-49f8-9ed6-1fe8a3991cca Clarity クリアオール clarity 3 TRUE TRUE FALSE 1 FALSE
17 4a00259a-9e2b-4239-bca2-2afdc2f52be7 c128944b-cc79-45b4-bfed-17c8b68db612 Dispel ディスペル dispel 1 TRUE TRUE FALSE 1 FALSE
18 67a126d1-5515-492f-aeaf-7f88b25e2e26 667bf041-61c9-4568-bdd3-ce6e43f40603 Dark Haze ブラックヘイズ dark-haze 1 FALSE FALSE TRUE 1 FALSE

View file

@ -0,0 +1,15 @@
"id","name_en","name_jp","proficiency1","proficiency2","row","master_level","order","base_job_id","granblue_id","accessory","accessory_type","ultimate_mastery"
"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","Fighter","ファイター",1,3,"1",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100001",FALSE,0,FALSE
"56aa0f3e-5cc1-49e7-a12d-a4d506064c9a","Warrior","ウオーリア",1,3,"2",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100001",FALSE,0,FALSE
"6283eb60-234f-4851-8cc7-7ea36e42def2","Weapon Master","ウェポンマスター",1,3,"3",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100201",FALSE,0,FALSE
"0e0c149d-8021-4f1e-a9d4-e2c77fd9e59a","Viking","ヴァイキング",1,3,"5",FALSE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100401",FALSE,0,FALSE
"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","Wizard","ウィザード",6,2,"1",FALSE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130001",FALSE,0,FALSE
"0b536736-669c-48d2-9b3c-a450f5183951","Sorcerer","ソーサラー",6,2,"2",FALSE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130001",FALSE,0,FALSE
"667bf041-61c9-4568-bdd3-ce6e43f40603","Warlock","ウォーロック",6,2,"4",TRUE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130301",FALSE,0,TRUE
"1eb55dd3-3278-4da1-8940-c4fc50c1a0f5","Berserker","ベルセルク",1,3,"4",TRUE,1,"67899c05-e73a-43ee-a83b-30fcd6e8ccf8","100301",FALSE,0,TRUE
"a5d6fca3-5649-4e12-a6db-5fcec49150ee","Manadiver","マナダイバー",6,2,"5",FALSE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130401",TRUE,2,FALSE
"43652444-64bb-4938-85d7-aafdfc503d66","Dark Fencer","ダークフェンサー",1,2,"3",FALSE,6,"21dff3a3-22bc-4863-9861-af0a1b41a5f0","150201",FALSE,0,FALSE
"d27a4f29-5f0b-4bc6-b75a-1bd187e1a529","Mystic","賢者",6,6,"ex1",FALSE,6,"d27a4f29-5f0b-4bc6-b75a-1bd187e1a529","250201",FALSE,0,FALSE
"2abbab55-5bf7-49f8-9ed6-1fe8a3991cca","Cleric","クレリック",6,4,"2",FALSE,3,"950f659b-0521-4a79-b578-7f3b05cb3102","120001",FALSE,0,FALSE
"667bf041-61c9-4568-bdd3-ce6e43f40603","Warlock","ウォーロック",6,2,"4",TRUE,4,"2b0cfead-50b3-4acd-8cb0-18aab243fdd1","130301",FALSE,0,TRUE
"c128944b-cc79-45b4-bfed-17c8b68db612","Bishop","ビショップ",6,4,"3",FALSE,3,"950f659b-0521-4a79-b578-7f3b05cb3102","120201",FALSE,0,FALSE
1 id name_en name_jp proficiency1 proficiency2 row master_level order base_job_id granblue_id accessory accessory_type ultimate_mastery
2 67899c05-e73a-43ee-a83b-30fcd6e8ccf8 Fighter ファイター 1 3 1 FALSE 1 67899c05-e73a-43ee-a83b-30fcd6e8ccf8 100001 FALSE 0 FALSE
3 56aa0f3e-5cc1-49e7-a12d-a4d506064c9a Warrior ウオーリア 1 3 2 FALSE 1 67899c05-e73a-43ee-a83b-30fcd6e8ccf8 100001 FALSE 0 FALSE
4 6283eb60-234f-4851-8cc7-7ea36e42def2 Weapon Master ウェポンマスター 1 3 3 FALSE 1 67899c05-e73a-43ee-a83b-30fcd6e8ccf8 100201 FALSE 0 FALSE
5 0e0c149d-8021-4f1e-a9d4-e2c77fd9e59a Viking ヴァイキング 1 3 5 FALSE 1 67899c05-e73a-43ee-a83b-30fcd6e8ccf8 100401 FALSE 0 FALSE
6 2b0cfead-50b3-4acd-8cb0-18aab243fdd1 Wizard ウィザード 6 2 1 FALSE 4 2b0cfead-50b3-4acd-8cb0-18aab243fdd1 130001 FALSE 0 FALSE
7 0b536736-669c-48d2-9b3c-a450f5183951 Sorcerer ソーサラー 6 2 2 FALSE 4 2b0cfead-50b3-4acd-8cb0-18aab243fdd1 130001 FALSE 0 FALSE
8 667bf041-61c9-4568-bdd3-ce6e43f40603 Warlock ウォーロック 6 2 4 TRUE 4 2b0cfead-50b3-4acd-8cb0-18aab243fdd1 130301 FALSE 0 TRUE
9 1eb55dd3-3278-4da1-8940-c4fc50c1a0f5 Berserker ベルセルク 1 3 4 TRUE 1 67899c05-e73a-43ee-a83b-30fcd6e8ccf8 100301 FALSE 0 TRUE
10 a5d6fca3-5649-4e12-a6db-5fcec49150ee Manadiver マナダイバー 6 2 5 FALSE 4 2b0cfead-50b3-4acd-8cb0-18aab243fdd1 130401 TRUE 2 FALSE
11 43652444-64bb-4938-85d7-aafdfc503d66 Dark Fencer ダークフェンサー 1 2 3 FALSE 6 21dff3a3-22bc-4863-9861-af0a1b41a5f0 150201 FALSE 0 FALSE
12 d27a4f29-5f0b-4bc6-b75a-1bd187e1a529 Mystic 賢者 6 6 ex1 FALSE 6 d27a4f29-5f0b-4bc6-b75a-1bd187e1a529 250201 FALSE 0 FALSE
13 2abbab55-5bf7-49f8-9ed6-1fe8a3991cca Cleric クレリック 6 4 2 FALSE 3 950f659b-0521-4a79-b578-7f3b05cb3102 120001 FALSE 0 FALSE
14 667bf041-61c9-4568-bdd3-ce6e43f40603 Warlock ウォーロック 6 2 4 TRUE 4 2b0cfead-50b3-4acd-8cb0-18aab243fdd1 130301 FALSE 0 TRUE
15 c128944b-cc79-45b4-bfed-17c8b68db612 Bishop ビショップ 6 4 3 FALSE 3 950f659b-0521-4a79-b578-7f3b05cb3102 120201 FALSE 0 FALSE

View file

@ -0,0 +1,23 @@
"id","name_en","name_jp","granblue_id","rarity","element","series","flb","ulb","max_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","subaura","limit","transcendence","max_atk_xlb","max_hp_xlb","summon_id","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence_date","nicknames_en","nicknames_jp"
"6db3991a-e72a-41fc-bad5-c7cc6e7f39ff","Orologia","オロロジャイア","2040433000",3,5,"0",TRUE,FALSE,150,145,836,1182,,310,2023,2880,,TRUE,FALSE,FALSE,,,3341,"2024-12-31","2024-12-31",,"Orologia_(Summon)","オロロジャイア (SSR)","479773","オロロジャイア",,"{}","{}"
"eaed64ed-2b1b-4f9f-a572-2032bbff197d","Wedges of the Sky","蒼空の楔","2040430000",3,1,,FALSE,FALSE,100,109,668,,,277,1740,,,TRUE,FALSE,FALSE,,,3338,"2024-08-09",,,"Wedges_of_the_Sky","蒼空の楔 (SSR)","458492","蒼空の楔",,"{boost,dragons}","{}"
"7d303689-3eb1-4193-80cd-e15de46af9d4","Beelzebub (Summer)","ベルゼバブ(水着ver)","2040429000",3,3,,TRUE,FALSE,150,115,793,1132,,442,2137,2985,,TRUE,FALSE,FALSE,,,3319,"2024-07-31","2024-07-31",,"Beelzebub_(Summer)","ベルゼバブ (SSR)水着","458158","水着ベルゼバブ",,"{babu,bubz}","{}"
"d169eaba-5493-45d4-a2f8-966850d784c9","Lu Woh","ル・オー","2040409000",3,6,"12",TRUE,FALSE,100,155,880,1243,,255,1800,2573,,TRUE,FALSE,FALSE,,,,"2023-02-28",,,"Lu Woh","%A5%EB%A1%A6%A5%AA%A1%BC%20%28SSR%29","385068","SSR/ルオー",,"{}","{}"
"42899ba7-dbf4-433f-8dd4-805010a1a627","Yatima","ヤチマ","2040417000",3,3,"0",TRUE,FALSE,150,138,1155,,,327,2870,,,TRUE,TRUE,FALSE,,,,"2022-12-31","2022-12-31",,"Yatima","%BE%A4%B4%AD%C0%D0%2F%A5%E4%A5%C1%A5%DE%20%28SSR%29","381024","SSR/ヤチマ",,"{}","{}"
"ad9abfc0-d919-4f75-aa15-0993d20d85ba","Beelzebub","ベルゼバブ","2040408000",3,5,"0",TRUE,FALSE,150,122,761,1081,,407,2317,3272,,TRUE,FALSE,FALSE,,,,"2021-12-31","2021-12-31",,"Beelzebub","%BE%A4%B4%AD%C0%D0%2F%A5%D9%A5%EB%A5%BC%A5%D0%A5%D6%20%28SSR%29","314288","ベルゼバブ",,"{bubz,bubs}","{}"
"0156d098-f5e7-4d72-8d14-34e6adff9280","The Moon","ザ・ムーン","2040243000",3,3,"6",TRUE,TRUE,200,110,668,952,1094,110,668,952,1094,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","The Moon (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%E0%A1%BC%A5%F3%20%28SSR%29","81835","SSR/ザ・ムーン",,"{}","{}"
"05214d59-2765-40c3-9a1d-6c29c6bdc6d6","Colossus Omega","コロッサス・マグナ","2040034000",3,2,"2",TRUE,TRUE,200,103,648,778,921,275,1635,1965,2315,TRUE,FALSE,TRUE,2665,1064,2001,"2014-10-08","2018-03-10","2020-08-08","Colossus Omega","%BE%A4%B4%AD%C0%D0%2F%A5%B3%A5%ED%A5%C3%A5%B5%A5%B9%A1%A6%A5%DE%A5%B0%A5%CA%20%28SSR%29","21736","SSR/コロッサス・マグナ","2024-05-02","{}","{}"
"d27d0d9a-3b38-4dcd-89a5-4016c2906249","Bahamut","バハムート","2040003000",3,5,"0",TRUE,TRUE,200,140,850,1210,1390,140,850,1210,1390,TRUE,FALSE,TRUE,,,,"2014-04-30","2017-03-10","2019-03-22","Bahamut","%BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29","21612","SSR/バハムート",,"{}","{}"
"fbeaa551-7ea1-48f3-982f-f4569d14fb45","Agni","アグニス","2040094000",3,2,"3",TRUE,TRUE,250,127,770,1092,1253,127,770,1092,3685,TRUE,FALSE,TRUE,,,,"2015-09-30","2019-08-27","2021-03-22","Agni","%BE%A4%B4%AD%C0%D0%2F%A5%A2%A5%B0%A5%CB%A5%B9%20%28SSR%29","147663","SSR/アグニス",,"{}","{}"
"04302038-a8dc-4860-a09a-257c0a6ac2a9","Lucifer","ルシフェル","2040056000",3,6,"0",TRUE,TRUE,250,136,900,1280,1470,136,900,1280,1470,FALSE,FALSE,TRUE,,,,"2014-12-31","2017-03-10","2019-03-22","Lucifer","%BE%A4%B4%AD%C0%D0%2F%A5%EB%A5%B7%A5%D5%A5%A7%A5%EB%20%28SSR%29","21599",,,"{}","{}"
"49bd4739-486a-4eca-990d-88431279ac3a","Qilin","黒麒麟","2040158000",3,5,,FALSE,FALSE,100,107,649,,,107,649,,,FALSE,FALSE,FALSE,,,,"2016-06-23",,,"Qilin",,,,,"{}","{}"
"4f6b3ccd-c906-43d6-9720-8328317cf6b2","The Sun","ザ・サン","2040244000",3,2,"6",TRUE,TRUE,200,106,664,948,1090,106,664,948,1090,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","The Sun (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%B5%A5%F3%20%28SSR%29","81834","SSR/ザ・サン",,"{}","{}"
"5c007586-588b-4eea-9bc5-d099f94af737","Wilnas","ウィルナス","2040398000",3,2,"12",TRUE,FALSE,100,127,771,1093,,399,2349,3324,,TRUE,FALSE,FALSE,,,,"2022-01-31",,,"Wilnas","%A5%A6%A5%A3%A5%EB%A5%CA%A5%B9%20%28SSR%29","317298","SSR/ウィルナス",,"{}","{}"
"597d6c56-73e3-424f-9971-8a237700fe08","Michael","ミカエル","2040306000",3,2,"5",TRUE,FALSE,150,129,832,1184,,359,2240,3181,,TRUE,FALSE,FALSE,,,,"2022-12-31",,,"Michael","%A5%DF%A5%AB%A5%A8%A5%EB%20%28SSR%29","381021","SSR/ミカエル",,"{}","{}"
"ad21f1d7-2b0a-4cd6-beb7-ce624d381f36","Triple Zero","トリプルゼロ","2040425000",3,6,"0",TRUE,FALSE,150,130,1140,,,367,2947,,,TRUE,TRUE,FALSE,,,,"2023-12-31","2023-12-31",,"Triple Zero","","","",,"{}","{}"
"1e3be2f2-803c-4cff-802d-785f3b682cfb","Hades","ハデス","2040090000",3,5,"3",TRUE,TRUE,250,123,755,1071,1229,123,755,1071,3798,TRUE,FALSE,TRUE,,,,"2015-08-31","2019-08-27","2021-03-22","Hades","%BE%A4%B4%AD%C0%D0%2F%A5%CF%A5%C7%A5%B9%20%28SSR%29","147575","SSR/ハデス",,"{}","{}"
"d27d0d9a-3b38-4dcd-89a5-4016c2906249","Bahamut","バハムート","2040003000",3,5,"0",TRUE,TRUE,200,140,850,1210,1390,140,850,1210,1390,TRUE,FALSE,TRUE,,,,"2014-04-30","2017-03-10","2019-03-22","Bahamut","%BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29","21612","SSR/バハムート",,"{}","{}"
"0fcfa02d-879f-4166-bc70-1f86a99a45ca","Sariel","サリエル","2040327000",3,5,"5",TRUE,FALSE,150,132,790,1119,,357,2155,3054,,TRUE,FALSE,FALSE,,,,"2019-04-30","2022-03-24",,"Sariel","%BE%A4%B4%AD%C0%D0%2F%A5%B5%A5%EA%A5%A8%A5%EB%20%28SSR%29","149228","SSR/サリエル",,"{}","{}"
"b203a9bc-9453-4090-91cc-84532b709d58","Zirnitra","ジルニトラ","2040385000",3,5,,FALSE,FALSE,100,128,806,,,128,806,,,TRUE,FALSE,FALSE,,,,"2020-09-30",,,"Zirnitra",,"230823",,,"{}","{}"
"83a6dfcb-0b74-4354-948c-2cff49b5b2b9","Fediel","フェディエル","2040418000",3,5,"12",TRUE,FALSE,150,132,810,1149,,374,2154,3044,,TRUE,FALSE,FALSE,,,,"2021-12-31",,,"Fediel","%A5%D5%A5%A7%A5%C7%A5%A3%A5%A8%A5%EB%20%28SSR%29","311659","SSR/フェディエル",,"{}","{}"
"fbde7c76-be0c-4a42-8479-046ed9715db9","Death","デス","2040238000",3,5,"6",TRUE,TRUE,200,109,695,984,1128,109,695,984,1128,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","Death (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%C7%A5%B9%20%28SSR%29","81843","SSR/デス",,"{}","{}"
1 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
2 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 オロロジャイア {} {}
3 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} {}
4 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} {}
5 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/ルオー {} {}
6 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/ヤチマ {} {}
7 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} {}
8 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/ザ・ムーン {} {}
9 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 {} {}
10 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/バハムート {} {}
11 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/アグニス {} {}
12 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 {} {}
13 49bd4739-486a-4eca-990d-88431279ac3a Qilin 黒麒麟 2040158000 3 5 FALSE FALSE 100 107 649 107 649 FALSE FALSE FALSE 2016-06-23 Qilin {} {}
14 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/ザ・サン {} {}
15 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/ウィルナス {} {}
16 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/ミカエル {} {}
17 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 {} {}
18 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/ハデス {} {}
19 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/バハムート {} {}
20 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/サリエル {} {}
21 b203a9bc-9453-4090-91cc-84532b709d58 Zirnitra ジルニトラ 2040385000 3 5 FALSE FALSE 100 128 806 128 806 TRUE FALSE FALSE 2020-09-30 Zirnitra 230823 {} {}
22 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/フェディエル {} {}
23 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/デス {} {}

View file

@ -0,0 +1,14 @@
"id","weapon_id","awakening_id"
"3f8be70e-db9f-41c0-91a7-b07cca9ed263","706438c4-a5eb-4f7c-a145-0c2a3e7e6fbe","18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7"
"59af97e7-8828-432e-9ff7-b2c792d08d70","ba7af3b3-c62f-4f85-a420-0321c776ba00","275c9de5-db1e-4c66-8210-660505fd1af4"
"97c4b396-597f-4622-9f6d-ee9536a6629b","ba7af3b3-c62f-4f85-a420-0321c776ba00","d691a61c-dc7e-4d92-a8e6-98c04608353c"
"b6b911bb-ee89-435f-b325-9df53a1ce6ea","ba7af3b3-c62f-4f85-a420-0321c776ba00","969d37db-5f14-4d1a-bef4-59ba5a016674"
"1dbff135-b401-4619-973d-740f4504ee3a","a2f0db22-baf1-4640-8c2e-6d283375744f","d691a61c-dc7e-4d92-a8e6-98c04608353c"
"d48fd874-484d-41c5-bff0-709cb714f7b0","a2f0db22-baf1-4640-8c2e-6d283375744f","275c9de5-db1e-4c66-8210-660505fd1af4"
"e793cc76-025d-4b6d-975a-58c56ff19141","47208685-e87a-4e07-b328-fb9ac3888718","d691a61c-dc7e-4d92-a8e6-98c04608353c"
"42ba1467-971e-40bd-b701-07538678cc95","e7a05d2e-a3ec-4620-98a5-d8472d474971","d691a61c-dc7e-4d92-a8e6-98c04608353c"
"6e94080f-1bbf-4171-8d77-40328c1daf1f","e7a05d2e-a3ec-4620-98a5-d8472d474971","969d37db-5f14-4d1a-bef4-59ba5a016674"
"714e3575-d536-4a77-870b-b5e2d8b31b68","e7a05d2e-a3ec-4620-98a5-d8472d474971","275c9de5-db1e-4c66-8210-660505fd1af4"
"5daffb43-f456-41db-8e04-dadc42bea788","8137294e-6bf1-4bac-a1e0-38cdc542622b","d691a61c-dc7e-4d92-a8e6-98c04608353c"
"ab83344b-b4ee-4aad-8e9b-1b7a8169575b","8137294e-6bf1-4bac-a1e0-38cdc542622b","275c9de5-db1e-4c66-8210-660505fd1af4"
"e26dbd37-b4d1-49f2-a5f2-36525a57b998","8137294e-6bf1-4bac-a1e0-38cdc542622b","969d37db-5f14-4d1a-bef4-59ba5a016674"
1 id weapon_id awakening_id
2 3f8be70e-db9f-41c0-91a7-b07cca9ed263 706438c4-a5eb-4f7c-a145-0c2a3e7e6fbe 18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7
3 59af97e7-8828-432e-9ff7-b2c792d08d70 ba7af3b3-c62f-4f85-a420-0321c776ba00 275c9de5-db1e-4c66-8210-660505fd1af4
4 97c4b396-597f-4622-9f6d-ee9536a6629b ba7af3b3-c62f-4f85-a420-0321c776ba00 d691a61c-dc7e-4d92-a8e6-98c04608353c
5 b6b911bb-ee89-435f-b325-9df53a1ce6ea ba7af3b3-c62f-4f85-a420-0321c776ba00 969d37db-5f14-4d1a-bef4-59ba5a016674
6 1dbff135-b401-4619-973d-740f4504ee3a a2f0db22-baf1-4640-8c2e-6d283375744f d691a61c-dc7e-4d92-a8e6-98c04608353c
7 d48fd874-484d-41c5-bff0-709cb714f7b0 a2f0db22-baf1-4640-8c2e-6d283375744f 275c9de5-db1e-4c66-8210-660505fd1af4
8 e793cc76-025d-4b6d-975a-58c56ff19141 47208685-e87a-4e07-b328-fb9ac3888718 d691a61c-dc7e-4d92-a8e6-98c04608353c
9 42ba1467-971e-40bd-b701-07538678cc95 e7a05d2e-a3ec-4620-98a5-d8472d474971 d691a61c-dc7e-4d92-a8e6-98c04608353c
10 6e94080f-1bbf-4171-8d77-40328c1daf1f e7a05d2e-a3ec-4620-98a5-d8472d474971 969d37db-5f14-4d1a-bef4-59ba5a016674
11 714e3575-d536-4a77-870b-b5e2d8b31b68 e7a05d2e-a3ec-4620-98a5-d8472d474971 275c9de5-db1e-4c66-8210-660505fd1af4
12 5daffb43-f456-41db-8e04-dadc42bea788 8137294e-6bf1-4bac-a1e0-38cdc542622b d691a61c-dc7e-4d92-a8e6-98c04608353c
13 ab83344b-b4ee-4aad-8e9b-1b7a8169575b 8137294e-6bf1-4bac-a1e0-38cdc542622b 275c9de5-db1e-4c66-8210-660505fd1af4
14 e26dbd37-b4d1-49f2-a5f2-36525a57b998 8137294e-6bf1-4bac-a1e0-38cdc542622b 969d37db-5f14-4d1a-bef4-59ba5a016674

View file

@ -0,0 +1,47 @@
"id","name_en","name_jp","slot","group","order","slug","granblue_id","series"
"02b40c48-b0d4-4df6-a27f-da2bc58fdd0f","Pendulum of Strife","闘争のペンデュラム",1,0,2,"pendulum-strife",14003,"{3}"
"0946e421-db65-403b-946f-5e2285e963f5","Pendulum of Sagacity","窮理のペンデュラム",1,2,2,"pendulum-sagacity",14006,"{3}"
"14534be3-defa-44cd-9096-09bae07565c8","Chain of Temperament","技錬のチェイン",1,1,0,"chain-temperament",14011,"{3}"
"1e2a1e5b-75f4-4e00-85d5-e5ef474dd6d7","Chain of Depravity","邪罪のチェイン",1,1,5,"chain-depravity",14016,"{3}"
"3faafaf1-5fc5-4aa8-8c65-894bbe1c615f","α Pendulum","アルファ・ペンデュラム",0,0,0,"pendulum-alpha",13001,"{3}"
"562c89bd-68cf-4a33-8609-d82e017130d6","Chain of Restoration","賦活のチェイン",1,1,1,"chain-restoration",14012,"{3}"
"5936e870-61a1-40a4-8c52-b85b9ab96967","Pendulum of Prosperity","隆盛のペンデュラム",1,0,3,"pendulum-prosperity",14004,"{3}"
"653477b7-5321-4ea4-8b6f-42218e67a090","Pendulum of Zeal","激情のペンデュラム",1,0,1,"pendulum-zeal",14002,"{3}"
"6ded911e-81d6-4fae-a3e7-682a5d18f2fc","Chain of Glorification","謳歌のチェイン",1,1,2,"chain-glorification",14013,"{3}"
"b3d8d4d8-8bf6-4e03-9f21-547653bf7574","Pendulum of Strength","強壮のペンデュラム",1,0,0,"pendulum-strength",14001,"{3}"
"c7a65d1f-c6a5-4c12-a90e-f3a31dc9d8f9","Pendulum of Extremity","絶涯のペンデュラム",1,2,1,"pendulum-extremity",14005,"{3}"
"d5b81056-fd58-45b6-b6ef-a43b45a15194","Chain of Temptation","誘惑のチェイン",1,1,3,"chain-temptation",14014,"{3}"
"d5ed9765-263e-4e28-b46a-a1f6bf8c6615","Pendulum of Supremacy","天髄のペンデュラム",1,2,3,"pendulum-supremacy",14007,"{3}"
"e719de37-500e-44cd-98a4-2d9af71e0809","Δ Pendulum","デルタ・ペンデュラム",0,0,4,"pendulum-delta",13004,"{3}"
"ebe424a0-7370-4b07-bd37-7eeee9b8425c","Chain of Falsehood","虚詐のチェイン",1,1,6,"chain-falsehood",14017,"{3}"
"ed19dcef-8579-4125-8607-5a43922d0999","β Pendulum","ベータ・ペンデュラム",0,0,1,"pendulum-beta",13002,"{3}"
"f5d711d8-f2f8-4909-9a64-ce6dc3584e03","γ Pendulum","ガンマ・ペンデュラム",0,0,2,"pendulum-gamma",13003,"{3}"
"f81ec8e8-acc8-4ad3-8460-b628e90cd29d","Chain of Forbiddance","禁忌のチェイン",1,1,4,"chain-forbiddance",14015,"{3}"
"0b696acb-baf4-4ad8-9caa-4255b338b13b","Gauph Key of Vitality","ガフスキー【生命】",0,3,2,"gauph-vitality",10003,"{13}"
"148e3323-395f-417c-b18a-96fd9421cfe6","Gauph Key of Strife","ガフスキー【闘争】",0,3,1,"gauph-strife",10002,"{13}"
"2ebe966e-0339-4464-acb9-0db138c3e2e7","Gauph Key of Will","ガフスキー【戦意】",0,3,0,"gauph-will",10001,"{13}"
"3ca1a71c-66bf-464a-8ad2-254c52169e8e","Gauph Key γ","ガフスキー【γ】",1,3,2,"gauph-gamma",11003,"{13}"
"3d5d610a-3734-444d-8818-fce2024a190b","Gauph Key Tria","ガフスキー【トリア】",2,3,2,"gauph-tria",17003,"{13}"
"4d6fefb6-09e6-4c92-98b0-a48b35ddd738","Gauph Key β","ガフスキー【β】",1,3,1,"gauph-beta",11002,"{13}"
"606632e3-3391-4223-8147-07060fe6f2e4","Gauph Key of Courage","ガフスキー【勇気】",0,3,5,"gauph-courage",10006,"{13}"
"6d03b9c2-08d8-49ea-8522-5507e9243ccc","Gauph Key α","ガフスキー【α】",1,3,0,"gauph-alpha",11001,"{13}"
"98a358bc-d123-40c9-8c0e-7953467c9a27","Gauph Key Δ","ガフスキー【Δ】",1,3,3,"gauph-delta",11004,"{13}"
"a1613dcd-dcc1-4290-95e7-3f9dfc28dd06","Gauph Key Tessera","ガフスキー【テーセラ】",2,3,3,"gauph-tessera",17004,"{13}"
"abd48244-8398-4159-ada6-9062803189f1","Gauph Key of Strength","ガフスキー【強壮】",0,3,3,"gauph-strength",10004,"{13}"
"cdd87f62-2d29-4698-b09d-8eef3f7b4406","Gauph Key Ena","ガフスキー【エナ】",2,3,0,"gauph-ena",17001,"{13}"
"d0dd2b46-cb55-4c2f-beb6-e2ee380bdb5e","Gauph Key Dio","ガフスキー【ディオ】",2,3,1,"gauph-dio",17002,"{13}"
"d6c0afdb-f6f3-4473-ada3-d505228ee348","Gauph Key of Zeal","ガフスキー【激情】",0,3,4,"gauph-zeal",10005,"{13}"
"44c2b0ba-642e-4edc-9680-1a34abe20418","Emblem of Devilry","魔獄のエンブレム",0,4,2,"emblem-devilry",3,"{19}"
"5ac2ad0a-f8da-403a-b098-7831d354f8e0","Emblem of Divinity","天聖のエンブレム",0,4,1,"emblem-divinity",2,"{19}"
"c2f1e5bc-9f8b-4af1-821c-2b32a9fb5f1f","Emblem of Humanity","英勇のエンブレム",0,4,0,"emblem-humanity",1,"{19}"
"0c6ce91c-864c-4c62-8c9b-be61e8fae47f","Optimus Teluma","オプティマス・テルマ",1,2,0,"teluma-optimus",16001,"{27,40}"
"1929bfa8-6bbd-4918-9ad7-594525b5e2c6","Crag Teluma","巨岩のテルマ",0,2,3,"teluma-crag",15004,"{27,40}"
"3fa65774-1ed1-4a16-86cd-9133adca2232","Omega Teluma","マグナ・テルマ",1,2,1,"teluma-omega",16002,"{27,40}"
"49f46e22-1796-435e-bce2-d9fdfe76d6c5","Tempest Teluma","暴風のテルマ",0,2,4,"teluma-tempest",15005,"{27,40}"
"81950efb-a4e1-4d45-8572-ddb604246212","Malice Teluma","闇禍のテルマ",0,2,6,"teluma-malice",15007,"{27,40}"
"d14e933e-630d-4cd6-9d61-dbdfd6e9332e","Abyss Teluma","深海のテルマ",0,2,2,"teluma-abyss",15003,"{27,40}"
"dc96edb7-8bee-4721-94c2-daa6508aaed8","Inferno Teluma","炎獄のテルマ",0,2,1,"teluma-inferno",15002,"{27,40}"
"e36950be-1ea9-4642-af94-164187e38e6c","Aureole Teluma","後光のテルマ",0,2,5,"teluma-aureole",15006,"{27,40}"
"ee80ff09-71c0-48bb-90ff-45e138df7481","Endurance Teluma","剛堅のテルマ",0,2,0,"teluma-endurance",15001,"{27,40}"
"b0b6d3be-7203-437e-8acd-2a59c2b5506a","Oblivion Teluma","冥烈のテルマ",0,2,8,"teluma-oblivion",15009,"{40}"
"d79558df-53fb-4c24-963b-e0b67040afc7","Salvation Teluma","燦護のテルマ",0,2,7,"teluma-salvation",15008,"{40}"
1 id name_en name_jp slot group order slug granblue_id series
2 02b40c48-b0d4-4df6-a27f-da2bc58fdd0f Pendulum of Strife 闘争のペンデュラム 1 0 2 pendulum-strife 14003 {3}
3 0946e421-db65-403b-946f-5e2285e963f5 Pendulum of Sagacity 窮理のペンデュラム 1 2 2 pendulum-sagacity 14006 {3}
4 14534be3-defa-44cd-9096-09bae07565c8 Chain of Temperament 技錬のチェイン 1 1 0 chain-temperament 14011 {3}
5 1e2a1e5b-75f4-4e00-85d5-e5ef474dd6d7 Chain of Depravity 邪罪のチェイン 1 1 5 chain-depravity 14016 {3}
6 3faafaf1-5fc5-4aa8-8c65-894bbe1c615f α Pendulum アルファ・ペンデュラム 0 0 0 pendulum-alpha 13001 {3}
7 562c89bd-68cf-4a33-8609-d82e017130d6 Chain of Restoration 賦活のチェイン 1 1 1 chain-restoration 14012 {3}
8 5936e870-61a1-40a4-8c52-b85b9ab96967 Pendulum of Prosperity 隆盛のペンデュラム 1 0 3 pendulum-prosperity 14004 {3}
9 653477b7-5321-4ea4-8b6f-42218e67a090 Pendulum of Zeal 激情のペンデュラム 1 0 1 pendulum-zeal 14002 {3}
10 6ded911e-81d6-4fae-a3e7-682a5d18f2fc Chain of Glorification 謳歌のチェイン 1 1 2 chain-glorification 14013 {3}
11 b3d8d4d8-8bf6-4e03-9f21-547653bf7574 Pendulum of Strength 強壮のペンデュラム 1 0 0 pendulum-strength 14001 {3}
12 c7a65d1f-c6a5-4c12-a90e-f3a31dc9d8f9 Pendulum of Extremity 絶涯のペンデュラム 1 2 1 pendulum-extremity 14005 {3}
13 d5b81056-fd58-45b6-b6ef-a43b45a15194 Chain of Temptation 誘惑のチェイン 1 1 3 chain-temptation 14014 {3}
14 d5ed9765-263e-4e28-b46a-a1f6bf8c6615 Pendulum of Supremacy 天髄のペンデュラム 1 2 3 pendulum-supremacy 14007 {3}
15 e719de37-500e-44cd-98a4-2d9af71e0809 Δ Pendulum デルタ・ペンデュラム 0 0 4 pendulum-delta 13004 {3}
16 ebe424a0-7370-4b07-bd37-7eeee9b8425c Chain of Falsehood 虚詐のチェイン 1 1 6 chain-falsehood 14017 {3}
17 ed19dcef-8579-4125-8607-5a43922d0999 β Pendulum ベータ・ペンデュラム 0 0 1 pendulum-beta 13002 {3}
18 f5d711d8-f2f8-4909-9a64-ce6dc3584e03 γ Pendulum ガンマ・ペンデュラム 0 0 2 pendulum-gamma 13003 {3}
19 f81ec8e8-acc8-4ad3-8460-b628e90cd29d Chain of Forbiddance 禁忌のチェイン 1 1 4 chain-forbiddance 14015 {3}
20 0b696acb-baf4-4ad8-9caa-4255b338b13b Gauph Key of Vitality ガフスキー【生命】 0 3 2 gauph-vitality 10003 {13}
21 148e3323-395f-417c-b18a-96fd9421cfe6 Gauph Key of Strife ガフスキー【闘争】 0 3 1 gauph-strife 10002 {13}
22 2ebe966e-0339-4464-acb9-0db138c3e2e7 Gauph Key of Will ガフスキー【戦意】 0 3 0 gauph-will 10001 {13}
23 3ca1a71c-66bf-464a-8ad2-254c52169e8e Gauph Key γ ガフスキー【γ】 1 3 2 gauph-gamma 11003 {13}
24 3d5d610a-3734-444d-8818-fce2024a190b Gauph Key Tria ガフスキー【トリア】 2 3 2 gauph-tria 17003 {13}
25 4d6fefb6-09e6-4c92-98b0-a48b35ddd738 Gauph Key β ガフスキー【β】 1 3 1 gauph-beta 11002 {13}
26 606632e3-3391-4223-8147-07060fe6f2e4 Gauph Key of Courage ガフスキー【勇気】 0 3 5 gauph-courage 10006 {13}
27 6d03b9c2-08d8-49ea-8522-5507e9243ccc Gauph Key α ガフスキー【α】 1 3 0 gauph-alpha 11001 {13}
28 98a358bc-d123-40c9-8c0e-7953467c9a27 Gauph Key Δ ガフスキー【Δ】 1 3 3 gauph-delta 11004 {13}
29 a1613dcd-dcc1-4290-95e7-3f9dfc28dd06 Gauph Key Tessera ガフスキー【テーセラ】 2 3 3 gauph-tessera 17004 {13}
30 abd48244-8398-4159-ada6-9062803189f1 Gauph Key of Strength ガフスキー【強壮】 0 3 3 gauph-strength 10004 {13}
31 cdd87f62-2d29-4698-b09d-8eef3f7b4406 Gauph Key Ena ガフスキー【エナ】 2 3 0 gauph-ena 17001 {13}
32 d0dd2b46-cb55-4c2f-beb6-e2ee380bdb5e Gauph Key Dio ガフスキー【ディオ】 2 3 1 gauph-dio 17002 {13}
33 d6c0afdb-f6f3-4473-ada3-d505228ee348 Gauph Key of Zeal ガフスキー【激情】 0 3 4 gauph-zeal 10005 {13}
34 44c2b0ba-642e-4edc-9680-1a34abe20418 Emblem of Devilry 魔獄のエンブレム 0 4 2 emblem-devilry 3 {19}
35 5ac2ad0a-f8da-403a-b098-7831d354f8e0 Emblem of Divinity 天聖のエンブレム 0 4 1 emblem-divinity 2 {19}
36 c2f1e5bc-9f8b-4af1-821c-2b32a9fb5f1f Emblem of Humanity 英勇のエンブレム 0 4 0 emblem-humanity 1 {19}
37 0c6ce91c-864c-4c62-8c9b-be61e8fae47f Optimus Teluma オプティマス・テルマ 1 2 0 teluma-optimus 16001 {27,40}
38 1929bfa8-6bbd-4918-9ad7-594525b5e2c6 Crag Teluma 巨岩のテルマ 0 2 3 teluma-crag 15004 {27,40}
39 3fa65774-1ed1-4a16-86cd-9133adca2232 Omega Teluma マグナ・テルマ 1 2 1 teluma-omega 16002 {27,40}
40 49f46e22-1796-435e-bce2-d9fdfe76d6c5 Tempest Teluma 暴風のテルマ 0 2 4 teluma-tempest 15005 {27,40}
41 81950efb-a4e1-4d45-8572-ddb604246212 Malice Teluma 闇禍のテルマ 0 2 6 teluma-malice 15007 {27,40}
42 d14e933e-630d-4cd6-9d61-dbdfd6e9332e Abyss Teluma 深海のテルマ 0 2 2 teluma-abyss 15003 {27,40}
43 dc96edb7-8bee-4721-94c2-daa6508aaed8 Inferno Teluma 炎獄のテルマ 0 2 1 teluma-inferno 15002 {27,40}
44 e36950be-1ea9-4642-af94-164187e38e6c Aureole Teluma 後光のテルマ 0 2 5 teluma-aureole 15006 {27,40}
45 ee80ff09-71c0-48bb-90ff-45e138df7481 Endurance Teluma 剛堅のテルマ 0 2 0 teluma-endurance 15001 {27,40}
46 b0b6d3be-7203-437e-8acd-2a59c2b5506a Oblivion Teluma 冥烈のテルマ 0 2 8 teluma-oblivion 15009 {40}
47 d79558df-53fb-4c24-963b-e0b67040afc7 Salvation Teluma 燦護のテルマ 0 2 7 teluma-salvation 15008 {40}

View file

@ -0,0 +1,24 @@
"id","name_en","name_jp","granblue_id","rarity","element","proficiency","series","flb","ulb","max_level","max_skill_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","extra","ax_type","limit","ax","nicknames_en","nicknames_jp","max_awakening_level","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence","transcendence_date","recruits"
"6c4f29c8-f43b-43f1-9fc5-967fb85c816e","Gauntlet of Proudearth","揺るがぬ大地の拳","1040611300",3,4,7,,TRUE,FALSE,150,15,35,240,290,,390,2300,2780,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-04-24","2019-04-24",,"Gauntlet of Proudearth",,,,FALSE,,
"302ded88-b5c9-4570-b422-66fc40277c4f","Ixaba","イクサバ","1040906400",3,2,10,,TRUE,FALSE,150,15,30,195,236,,502,3000,3620,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2017-03-31","2018-05-21",,"Ixaba",,"72189","イクサバ",FALSE,,"3040115000"
"b540fbaf-48c9-41c0-981f-05953319b409","Skeletal Eclipse","呪蝕の骸槍","1040216900",3,5,4,,TRUE,FALSE,150,15,43,280,339,,441,2547,3074,,FALSE,0,FALSE,FALSE,"{}","{}",,"2021-12-31","2021-12-31",,"Skeletal Eclipse","%C9%F0%B4%EF%2F%BC%F6%BF%AA%A4%CE%B3%BC%C1%E4%20%28SSR%29","314295","呪蝕の骸槍",FALSE,,"3040376000"
"a2025b78-5c72-4efa-9fbf-c9fdc2aa2364","Katana of Repudiation","絶対否定の太刀","1040911000",3,5,10,,TRUE,TRUE,200,20,28,189,229,269,465,2765,3340,3915,FALSE,0,TRUE,FALSE,"{}","{}",,"2019-04-11","2019-04-11","2019-04-11","Katana of Repudiation","{{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}}","{{{link_gamewith|146896}}}","{{{link_kamigame|{{{jpname|}}}}}}",TRUE,"2024-01-15",
"aa6f8b9b-ed78-4b1a-8693-acefd5b455fc","Scythe of Repudiation","絶対否定の大鎌","1040310600",3,2,3,,TRUE,TRUE,200,20,30,195,236,277,450,2730,3300,3870,FALSE,0,TRUE,FALSE,"{}","{}",,"2019-04-11","2019-04-11","2019-04-11","Scythe of Repudiation","{{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}}","{{{link_gamewith|146896}}}","{{{link_kamigame|{{{jpname|}}}}}}",TRUE,"2024-01-15",
"c6e4eeaa-bd19-466e-81ea-58310ed5cf25","Draconic Blade","ドラゴニックブレイド","1040912100",3,6,10,,TRUE,TRUE,200,20,32,193,233,273,445,2744,3319,3894,FALSE,0,TRUE,FALSE,"{}","{}",,"2020-03-10","2020-03-10","2020-03-10","Draconic Blade","%C9%F0%B4%EF%2F%A5%C9%A5%E9%A5%B4%A5%CB%A5%C3%A5%AF%A5%D6%A5%EC%A5%A4%A5%C9%20%28SSR%29","190367",,FALSE,,
"1cedbb93-79ef-41ef-915f-94961ef9eba8","Nine-Realm Harp (Awakened)","九界琴・覚醒","1040801400",3,0,8,4,FALSE,FALSE,100,10,75,275,,,380,2470,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-03-10",,,"Nine-Realm Harp (Awakened)",,,,FALSE,,
"a5d72b41-6dea-4179-9996-36c01d2dad32","Winter's Frostnettle","冬ノ霜柱","1040111300",3,3,2,,TRUE,FALSE,150,15,21,189,228,,290,1857,2249,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-07-12","2019-07-12",,"Winter%27s Frostnettle","%C9%F0%B4%EF%2F%C5%DF%A5%CE%C1%FA%C3%EC%20%28SSR%29","158278","冬ノ霜柱",FALSE,,
"620fbcd5-7c2e-4949-8cad-bbfb0908b00f","Ecke Sachs","エッケザックス","1040007100",3,2,1,,TRUE,FALSE,150,15,106,664,800,,278,1677,2030,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2020-04-07",,"Ecke Sachs","%C9%F0%B4%EF%2F%A5%A8%A5%C3%A5%B1%A5%B6%A5%C3%A5%AF%A5%B9%20%28SSR%29","71702","エッケザックス",FALSE,,
"8cebe3c3-be12-4985-b45d-3e9db8204e6e","Ray of Zhuque Malus","朱雀光剣・邪","1040906700",3,2,10,,TRUE,TRUE,200,20,22,145,175,205,345,2090,2530,2970,FALSE,0,TRUE,FALSE,"{}","{}",,"2017-04-10","2017-04-10","2022-04-07","Ray of Zhuque Malus",,"75564","朱雀光剣・邪",FALSE,,
"4380828f-1acc-46cd-b7eb-1cb8d34ca9ec","Last Storm Harp","ラストストームハープ","1040808300",3,1,8,,TRUE,FALSE,150,15,62,223,260,,337,2059,2400,,FALSE,0,FALSE,FALSE,"{}","{}",,"2018-03-10","2018-03-10",,"Last Storm Harp",,,,FALSE,,
"ec3ba18a-9417-4ebe-a898-a74d5f15385f","Pillar of Flame","炎の柱","1040215200",3,6,4,,TRUE,FALSE,150,15,37,213,250,,341,2250,2630,,FALSE,0,FALSE,FALSE,"{}","{}",,"2020-08-31","2020-08-31",,"Pillar of Flame","%C9%F0%B4%EF%2F%B1%EA%A4%CE%C3%EC%20%28SSR%29","225789","炎の柱",FALSE,,
"d61ee84f-4520-4064-8ff9-42a899273316","Luminiera Sword Omega","シュヴァリエソード・マグナ","1040007200",3,6,1,,TRUE,TRUE,200,20,31,195,228,244,370,2275,2660,2850,FALSE,1,FALSE,TRUE,"{}","{}",,"2014-03-10",,"2018-03-10","Luminiera Sword Omega",,,,FALSE,,
"9f94d1e5-a117-432f-9da4-f3a5022b666d","Bow of Sigurd","シグルズの弓","1040705100",3,3,5,,TRUE,FALSE,150,15,36,214,250,,365,2311,2701,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2018-07-15",,"Bow of Sigurd",,,,FALSE,,
"a4441a22-4704-4fbc-a543-77d3b952e921","Pain and Suffering","ペイン・アンド・ストレイン","1040314300",3,5,3,,TRUE,FALSE,150,15,50,410,500,,410,1890,2260,,FALSE,0,FALSE,FALSE,"{pns}","{}",,"2021-09-15","2021-09-15",,"Pain and Suffering","%C9%F0%B4%EF%2F%A5%DA%A5%A4%A5%F3%A1%A6%A5%A2%A5%F3%A5%C9%A1%A6%A5%B9%A5%C8%A5%EC%A5%A4%A5%F3%20%28SSR%29","294337","ペイン・アンド・ストレイン",FALSE,,"3040357000"
"f4460b37-ab5b-4252-bd79-009a8819ee25","Eternal Signature","永遠の落款","1040116600",3,5,2,,TRUE,FALSE,150,15,40,259,,,459,2562,,,FALSE,,FALSE,FALSE,"{es,""halmal dagger""}","{}",,"2023-01-19","2023-01-19",,"Eternal Signature","%C9%F0%B4%EF%2F%B1%CA%B1%F3%A4%CE%CD%EE%B4%BE%20%28SSR%29","384946",,FALSE,,"3040443000"
"07dd062a-640c-4f00-9943-614b9f031271","Ultima Claw","オメガクロー","1040608100",3,0,7,,TRUE,TRUE,200,20,35,277,313,349,393,2717,3066,3415,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10","2021-12-03","2021-12-03","Ultima Claw",,,,FALSE,,
"33d75927-70e9-49ba-8494-fb67b4567540","Blutgang","ブルトガング","1040008700",3,5,1,,TRUE,FALSE,150,15,36,234,280,,480,2790,3370,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2016-04-28","2017-11-17",,"Blutgang","%C9%F0%B4%EF%2F%A5%D6%A5%EB%A5%C8%A5%AC%A5%F3%A5%B0%20%28SSR%29","71711",,FALSE,,"3040082000"
"1b3b84fd-eefa-4845-8fd0-b4452482e716","Bab-el-Mandeb","バブ・エル・マンデブ","1040311600",3,5,3,,TRUE,FALSE,150,15,31,207,251,,503,2915,3518,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-12-28","2019-12-28",,"Bab-el-Mandeb",,,,FALSE,,"3040251000"
"dd199867-ec7b-4067-8886-1fa02e1095b4","Celeste Zaghnal Omega","セレストザグナル・マグナ","1040301400",3,5,3,,TRUE,TRUE,200,20,24,169,198,213,405,2405,2810,3010,FALSE,1,FALSE,TRUE,"{}","{}",,"2014-03-10",,"2018-03-10","Celeste Zaghnal Omega",,"71937",,FALSE,,
"cddf9de4-ee8f-4978-9901-0ec7f2601927","Pain of Death","ペイン・オブ・デス","1040113200",3,5,2,,TRUE,TRUE,200,20,32,218,265,312,379,2241,2707,3173,TRUE,0,FALSE,FALSE,"{}","{}",,"2020-12-04","2022-02-21","2022-12-26","Pain of Death","%C9%F0%B4%EF%2F%A5%DA%A5%A4%A5%F3%A1%A6%A5%AA%A5%D6%A1%A6%A5%C7%A5%B9%20%28SSR%29","{{{link_gamewith|220273}}}",,FALSE,,
"38df4067-db48-4dbc-b1cf-c26e019137d8","Parazonium","パラゾニウム","1040108700",3,5,2,,TRUE,FALSE,150,15,40,259,310,,459,2652,3200,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2017-02-28","2018-02-14",,"Parazonium","%C9%F0%B4%EF%2F%A5%D1%A5%E9%A5%BE%A5%CB%A5%A6%A5%E0%20%28SSR%29","71768","パラゾニウム",FALSE,,"3040111000"
"36959849-1ff6-4317-992e-2287b31138eb","Dagger of Bahamut Coda","バハムートダガー・フツルス","1040106700",3,5,2,,TRUE,TRUE,200,20,34,229,268,307,395,2355,2750,3145,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10",,"2021-12-03","Dagger of Bahamut Coda",,,,FALSE,,
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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

View file

@ -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, name_en,name_jp,granblue_id,release_date,wiki_en,wiki_ja,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,nicknames_en,nicknames_jp,flb_date,ulb_date,gamewith,kamigame,
,,3040093000,,,,シャノワール (SSR),,,,,,,true,260,1300,1560,1550,8700,10250,,,,,,,,,,,,2024-09-24,,,SSRシャワール, ,,3040093000,,,,,,,,,,,true,260,1300,1560,1550,8700,10250,,,,,,,,,,,,2024-09-24,,,SSRシャワール,

1 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
2 3040093000 シャノワール (SSR) true 260 1300 1560 1550 8700 10250 2024-09-24 SSRシャノワール

View file

@ -1,8 +1,8 @@
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , , , name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , , ,
Rubea Stiria,ルベウス・スティーリア,1040713800,3,2,5,1,true,false,150,15,39,225,272,,473,2885,3488,,false,,false,false,3040566000,,2024-12-28,,,Rubea_Stiria,ルベウス・スティーリア (SSR),480642,ルベウス・スティーリア,,,false,,,,,,,,,,,,,,, Rubea Stiria,ルベウス・スティーリア,1040713800,3,2,5,1,true,false,150,15,39,225,272,,473,2885,3488,,false,,false,false,3040566000,,2024-12-28,2024-12-28,,Rubea_Stiria,ルベウス・スティーリア (SSR),480642,ルベウス・スティーリア,,,false,,,,,,,,,,,,,,,
Shroudsword Verveine,秘刀ヴェルヴェーヌ,1040917000,3,5,10,36,false,false,100,10,24,158,,,485,2817,,,false,,false,false,3040567000,,2024-12-28,,,Shroudsword_Verveine,秘刀ヴェルヴェーヌ (SSR),480658,秘刀ヴェルヴェーヌ,,,false,,,,,,,,,,,,,,, Shroudsword Verveine,秘刀ヴェルヴェーヌ,1040917000,3,5,10,36,false,false,100,10,24,158,,,485,2817,,,false,,false,false,3040567000,,2024-12-28,,,Shroudsword_Verveine,秘刀ヴェルヴェーヌ (SSR),480658,秘刀ヴェルヴェーヌ,,,false,,,,,,,,,,,,,,,
Galgalim of Gales,天風の鋭輪,1040619500,3,1,7,1,true,false,150,15,42,270,327,,450,2599,3136,,false,,false,false,3040568000,,2024-12-31,,,Galgalim_of_Gales,天風の鋭輪 (SSR),480869,天風の鋭輪,,,false,,,,,,,,,,,,,,, Galgalim of Gales,天風の鋭輪,1040619500,3,1,7,1,true,false,150,15,42,270,327,,450,2599,3136,,false,,false,false,3040568000,,2024-12-31,2024-12-31,,Galgalim_of_Gales,天風の鋭輪 (SSR),480869,天風の鋭輪,,,false,,,,,,,,,,,,,,,
Shiny Cane,ケーン・オブ・シャイニー,1040423900,3,6,6,36,false,false,100,10,51,290,,,344,2138,,,false,,false,false,3040570000,,2024-12-31,,,Shiny_Cane,ケーン・オブ・シャイニー (SSR),480882,ケーン・オブ・シャイニー,,,false,,,,,,,,,,,,,,, Shiny Cane,ケーン・オブ・シャイニー,1040423900,3,6,6,36,false,false,100,10,51,290,,,344,2138,,,false,,false,false,3040570000,,2024-12-31,,,Shiny_Cane,ケーン・オブ・シャイニー (SSR),480882,ケーン・オブ・シャイニー,,,false,,,,,,,,,,,,,,,
Serpentius,天干地支剣・巳之飾,1040028100,3,5,1,36,true,false,150,15,47,311,377,,364,2025,2440,,false,,false,false,3040569000,,2024-12-31,,,Serpentius,天干地支剣・巳之飾 (SSR),480877,天干地支剣・巳之飾,,,false,,,,,,,,,,,,,,, Serpentius,天干地支剣・巳之飾,1040028100,3,5,1,36,true,false,150,15,47,311,377,,364,2025,2440,,false,,false,false,3040569000,,2024-12-31,2024-12-31,,Serpentius,天干地支剣・巳之飾 (SSR),480877,天干地支剣・巳之飾,,,false,,,,,,,,,,,,,,,
Scorn of the Goblin King,嘲りの鬼王槍,1040219600,3,4,4,35,false,false,100,10,25,199,,,337,1884,,,false,,false,false,,,2024-12-29,,,Scorn_of_the_Goblin_King,嘲りの鬼王槍 (SSR),480681,嘲りの鬼王槍,,,false,,,,,,,,,,,,,,, Scorn of the Goblin King,嘲りの鬼王槍,1040219600,3,4,4,35,false,false,100,10,25,199,,,337,1884,,,false,,false,false,,,2024-12-29,,,Scorn_of_the_Goblin_King,嘲りの鬼王槍 (SSR),480681,嘲りの鬼王槍,,,false,,,,,,,,,,,,,,,
Stone Ilhoon,ストーン・イルウーン,1030010400,2,4,1,35,false,false,75,10,15,98,,,236,1463,,,false,,false,false,,,2024-12-29,,,Stone_Ilhoon,ストーン・イルウーン (SR),,ストーン・イルウーン,,,false,,,,,,,,,,,,,,, Stone Ilhoon,ストーン・イルウーン,1030010400,2,4,1,35,false,false,75,10,15,98,,,236,1463,,,false,,false,false,,,2024-12-29,,,Stone_Ilhoon,ストーン・イルウーン (SR),,ストーン・イルウーン,,,false,,,,,,,,,,,,,,,

1 name_en name_jp granblue_id rarity element proficiency series flb ulb max_level max_skill_level min_hp max_hp max_hp_flb max_hp_ulb min_atk max_atk max_atk_flb max_atk_ulb extra ax_type limit ax recruits max_awakening_level release_date flb_date ulb_date wiki_en wiki_ja gamewith kamigame nicknames_en nicknames_jp transcendence transcendence_date
2 Rubea Stiria ルベウス・スティーリア 1040713800 3 2 5 1 true false 150 15 39 225 272 473 2885 3488 false false false 3040566000 2024-12-28 2024-12-28 Rubea_Stiria ルベウス・スティーリア (SSR) 480642 ルベウス・スティーリア false
3 Shroudsword Verveine 秘刀ヴェルヴェーヌ 1040917000 3 5 10 36 false false 100 10 24 158 485 2817 false false false 3040567000 2024-12-28 Shroudsword_Verveine 秘刀ヴェルヴェーヌ (SSR) 480658 秘刀ヴェルヴェーヌ false
4 Galgalim of Gales 天風の鋭輪 1040619500 3 1 7 1 true false 150 15 42 270 327 450 2599 3136 false false false 3040568000 2024-12-31 2024-12-31 Galgalim_of_Gales 天風の鋭輪 (SSR) 480869 天風の鋭輪 false
5 Shiny Cane ケーン・オブ・シャイニー 1040423900 3 6 6 36 false false 100 10 51 290 344 2138 false false false 3040570000 2024-12-31 Shiny_Cane ケーン・オブ・シャイニー (SSR) 480882 ケーン・オブ・シャイニー false
6 Serpentius 天干地支剣・巳之飾 1040028100 3 5 1 36 true false 150 15 47 311 377 364 2025 2440 false false false 3040569000 2024-12-31 2024-12-31 Serpentius 天干地支剣・巳之飾 (SSR) 480877 天干地支剣・巳之飾 false
7 Scorn of the Goblin King 嘲りの鬼王槍 1040219600 3 4 4 35 false false 100 10 25 199 337 1884 false false false 2024-12-29 Scorn_of_the_Goblin_King 嘲りの鬼王槍 (SSR) 480681 嘲りの鬼王槍 false
8 Stone Ilhoon ストーン・イルウーン 1030010400 2 4 1 35 false false 75 10 15 98 236 1463 false false false 2024-12-29 Stone_Ilhoon ストーン・イルウーン (SR) ストーン・イルウーン false

View file

@ -0,0 +1,9 @@
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
Maleagant,メレアガンス,3040572000,3,4,7,,1,0,,false,410,1970,,1100,6950,,,,,,false,false,,,{3265},Maleagant,2025-01-17,,,メレアガンス (SSR),482404,SSRメレアガンス,,
Feena (Light),フィーナ(光属性ver),3040573000,3,6,5,,2,1,,false,176,1216,,2120,9920,,,,,,false,false,,,{2032},Feena (Light),2025-01-17,,,フィーナ (SSR)光属性,482405,SSR光フィーナ,,
Jack Rakan,ジャック・ラカン,3040577000,3,4,7,1,1,0,,false,260,1388,,2000,10660,,7,3,,,false,false,,,{3269},Jack Rakan,2025-01-31,,,ジャック・ラカン (SSR),483351,SSRジャックラカン,,
Setsuna Sakurazaki,桜咲刹那,3040576000,3,1,10,7,2,0,,false,243,1300,,1785,9500,,,,,,false,false,,,{3268},Setsuna Sakurazaki,2025-01-31,,,桜咲刹那 (SSR),483350,SSR桜咲刹那,,
Negi Springfield,ネギ・スプリングフィールド,3040574000,3,1,6,7,1,1,,false,280,1550,,1600,8250,,7,3,,,false,false,,,{3266},Negi Springfield,2025-02-04,,,ネギ・スプリングフィールド (SSR),479782,SSRネギスプリングフィールド,,
Evangeline A.K. McDowell,エヴァンジェリン・A・K・マクダウェル,3040575000,3,5,6,7,2,0,,false,220,1250,,1900,9750,,10,5,,,false,false,,,{3267},Evangeline A.K. McDowell,2025-02-10,,,エヴァンジェリン・A・K・マクダウェル (SSR),479781,SSRエヴァンジェリンマクダウェル,,
Makura (Valentine),マコラ(バレンタインver),3040579000,3,6,10,,2,2,,false,214,1130,,1630,8750,,,,,,false,false,,,{3218},Makura (Valentine),2025-02-14,,,マコラ (SSR)バレンタインバージョン,485114,SSRバレンタインマコラ,,
Lobelia (Valentine),ロベリア(バレンタインver),3040578000,3,5,6,7,1,1,,false,306,1606,,1470,7970,,7,3,,,false,false,,,{3104},Lobelia (Valentine),2025-02-14,,,ロベリア (SSR)バレンタインバージョン,485115,SSRバレンタインロベリア,,
1 name_en name_jp granblue_id rarity element proficiency1 proficiency2 gender race1 race2 flb min_hp max_hp max_hp_flb min_atk max_atk max_atk_flb base_da base_ta ougi_ratio ougi_ratio_flb special ulb max_hp_ulb max_atk_ulb character_id wiki_en release_date flb_date ulb_date wiki_ja gamewith kamigame nicknames_en nicknames_jp
2 Maleagant メレアガンス 3040572000 3 4 7 1 0 false 410 1970 1100 6950 false false {3265} Maleagant 2025-01-17 メレアガンス (SSR) 482404 SSRメレアガンス
3 Feena (Light) フィーナ(光属性ver) 3040573000 3 6 5 2 1 false 176 1216 2120 9920 false false {2032} Feena (Light) 2025-01-17 フィーナ (SSR)光属性 482405 SSR光フィーナ
4 Jack Rakan ジャック・ラカン 3040577000 3 4 7 1 1 0 false 260 1388 2000 10660 7 3 false false {3269} Jack Rakan 2025-01-31 ジャック・ラカン (SSR) 483351 SSRジャックラカン
5 Setsuna Sakurazaki 桜咲刹那 3040576000 3 1 10 7 2 0 false 243 1300 1785 9500 false false {3268} Setsuna Sakurazaki 2025-01-31 桜咲刹那 (SSR) 483350 SSR桜咲刹那
6 Negi Springfield ネギ・スプリングフィールド 3040574000 3 1 6 7 1 1 false 280 1550 1600 8250 7 3 false false {3266} Negi Springfield 2025-02-04 ネギ・スプリングフィールド (SSR) 479782 SSRネギスプリングフィールド
7 Evangeline A.K. McDowell エヴァンジェリン・A・K・マクダウェル 3040575000 3 5 6 7 2 0 false 220 1250 1900 9750 10 5 false false {3267} Evangeline A.K. McDowell 2025-02-10 エヴァンジェリン・A・K・マクダウェル (SSR) 479781 SSRエヴァンジェリンAKマクダウェル
8 Makura (Valentine) マコラ(バレンタインver) 3040579000 3 6 10 2 2 false 214 1130 1630 8750 false false {3218} Makura (Valentine) 2025-02-14 マコラ (SSR)バレンタインバージョン 485114 SSRバレンタインマコラ
9 Lobelia (Valentine) ロベリア(バレンタインver) 3040578000 3 5 6 7 1 1 false 306 1606 1470 7970 7 3 false false {3104} Lobelia (Valentine) 2025-02-14 ロベリア (SSR)バレンタインバージョン 485115 SSRバレンタインロベリア

View file

@ -0,0 +1,2 @@
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
,,3040103000,,,,,,,,true,210,1150,1360,1540,7970,9510,,,,,,,,,,,,2025-02-20,,アンスリア (SSR),45808,SSRアンスリア,,
1 name_en name_jp granblue_id rarity element proficiency1 proficiency2 gender race1 race2 flb min_hp max_hp max_hp_flb min_atk max_atk max_atk_flb base_da base_ta ougi_ratio ougi_ratio_flb special ulb max_hp_ulb max_atk_ulb character_id wiki_en release_date flb_date ulb_date wiki_ja gamewith kamigame nicknames_en nicknames_jp
2 3040103000 true 210 1150 1360 1540 7970 9510 2025-02-20 アンスリア (SSR) 45808 SSRアンスリア

View file

@ -0,0 +1,2 @@
name_en,name_jp,granblue_id,rarity,element,series,flb,ulb,max_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,subaura,limit,transcendence,max_atk_xlb,max_hp_xlb,summon_id,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,transcendence_date,nicknames_en,nicknames_jp
Asuna Kagurazaka,神楽坂明日菜,2040434000,3,6,98,true,false,150,90,569,689,,245,1477,1785,,false,false,false,,,{3342},2025-02-04,2025-02-04,,Asuna Kagurazaka,神楽坂明日菜 (SSR),479783,神楽坂明日菜,,,
1 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
2 Asuna Kagurazaka 神楽坂明日菜 2040434000 3 6 98 true false 150 90 569 689 245 1477 1785 false false false {3342} 2025-02-04 2025-02-04 Asuna Kagurazaka 神楽坂明日菜 (SSR) 479783 神楽坂明日菜

View file

@ -0,0 +1,12 @@
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
Uralter Nagel,アルト・ナーゲル,1040619800,3,4,7,99,false,false,100,10,52,260,,,336,2289,,,false,,false,false,,,2025-01-17,,,Uralter Nagel,アルト・ナーゲル (SSR),482412,アルト・ナーゲル,,,,,,,,,,,,,,,,,
Greatbow Rondor,宝弓ロンドール,1040713900,3,6,5,99,true,false,150,15,33,223,271,,442,2525,3046,,false,,false,false,,,2025-01-17,2025-01-17,,Greatbow Rondor,宝弓ロンドール (SSR),482411,宝弓ロンドール,,,,,,,,,,,,,,,,,
Protean Folding Fan,変幻之妙扇,1040619900,3,2,7,98,false,false,100,10,27,195,,,328,1907,,,false,,false,false,,,2025-01-29,,,Protean Folding Fan,変幻之妙扇 (SSR),483280,変幻之妙扇,,,,,,,,,,,,,,,,,
Opening Shamisen,出囃子三絃,1030804700,2,2,8,98,false,false,75,10,21,147,,,206,1220,,,false,,false,false,,,2025-01-29,,,Opening Shamisen,出囃子三絃 (SR),,出囃子三絃,,,,,,,,,,,,,,,,,
Twinpain-Wolf Gun,双創・凱狼雷,1040517100,3,2,9,41,true,false,150,15,30,192,233,,481,2913,3521,,false,,false,false,,,2025-01-22,2025-01-22,,Twinpain-Wolf Gun,双創・凱狼雷 (SSR),482723,双創・凱狼雷,,,,,,,,,,,,,,,,,
O Iros Meta Chilion Prosopon,千の顔を持つ英雄,1040028200,3,4,1,97,true,false,150,15,35,206,249,,424,2557,3090,,false,,false,false,,,2025-01-31,2025-01-31,,O Iros Meta Chilion Prosopon,千の顔を持つ英雄 (SSR),483716,千の顔を持つ英雄,,,,,,,,,,,,,,,,,
Sica Shishikushiro,匕首・十六串呂,1040121200,3,1,2,97,true,false,150,15,37,235,285,,414,2412,2912,,false,,false,false,,,2025-01-31,2025-01-31,,Sica Shishikushiro,匕首・十六串呂 (SSR),483715,匕首・十六串呂,,,,,,,,,,,,,,,,,
Negi's Staff,ネギの杖,1040424000,3,1,6,97,true,false,150,15,38,239,289,,273,1684,2037,,false,,false,false,,,2025-02-04,2025-02-04,,Negi%27s_Staff,ネギの杖 (SSR),483872,ネギの杖,,,,,,,,,,,,,,,,,
Ensis Exorcizans,ハマノツルギ,1040028300,3,6,1,97,true,false,150,15,20,135,164,,364,2207,2668,,false,,false,false,,,2025-02-04,2025-02-04,,Ensis Exorcizans,ハマノツルギ (SSR),483873,ハマノツルギ,,,,,,,,,,,,,,,,,
Fourth Fork of the Fluffle,第四卯行突匙,1040219700,3,4,4,99,true,false,150,15,41,248,300,,393,2346,2834,,false,,false,false,,,2025-02-14,2025-02-14,,Fourth Fork of the Fluffle,第四卯行突匙 (SSR),485117,第四卯行突匙,,,,,,,,,,,,,,,,,
Clapotis Douleur,ドゥルール・クラポティ,1040319200,3,5,3,99,false,false,100,10,23,171,,,484,2731,,,false,,false,false,,,2025-02-14,,,Clapotis Douleur,ドゥルール・クラポティ (SSR),485128,ドゥルール・クラポティ,,,,,,,,,,,,,,,,,
1 name_en name_jp granblue_id rarity element proficiency series flb ulb max_level max_skill_level min_hp max_hp max_hp_flb max_hp_ulb min_atk max_atk max_atk_flb max_atk_ulb extra ax_type limit ax recruits max_awakening_level release_date flb_date ulb_date wiki_en wiki_ja gamewith kamigame nicknames_en nicknames_jp transcendence transcendence_date
2 Uralter Nagel アルト・ナーゲル 1040619800 3 4 7 99 false false 100 10 52 260 336 2289 false false false 2025-01-17 Uralter Nagel アルト・ナーゲル (SSR) 482412 アルト・ナーゲル
3 Greatbow Rondor 宝弓ロンドール 1040713900 3 6 5 99 true false 150 15 33 223 271 442 2525 3046 false false false 2025-01-17 2025-01-17 Greatbow Rondor 宝弓ロンドール (SSR) 482411 宝弓ロンドール
4 Protean Folding Fan 変幻之妙扇 1040619900 3 2 7 98 false false 100 10 27 195 328 1907 false false false 2025-01-29 Protean Folding Fan 変幻之妙扇 (SSR) 483280 変幻之妙扇
5 Opening Shamisen 出囃子三絃 1030804700 2 2 8 98 false false 75 10 21 147 206 1220 false false false 2025-01-29 Opening Shamisen 出囃子三絃 (SR) 出囃子三絃
6 Twinpain-Wolf Gun 双創・凱狼雷 1040517100 3 2 9 41 true false 150 15 30 192 233 481 2913 3521 false false false 2025-01-22 2025-01-22 Twinpain-Wolf Gun 双創・凱狼雷 (SSR) 482723 双創・凱狼雷
7 O Iros Meta Chilion Prosopon 千の顔を持つ英雄 1040028200 3 4 1 97 true false 150 15 35 206 249 424 2557 3090 false false false 2025-01-31 2025-01-31 O Iros Meta Chilion Prosopon 千の顔を持つ英雄 (SSR) 483716 千の顔を持つ英雄
8 Sica Shishikushiro 匕首・十六串呂 1040121200 3 1 2 97 true false 150 15 37 235 285 414 2412 2912 false false false 2025-01-31 2025-01-31 Sica Shishikushiro 匕首・十六串呂 (SSR) 483715 匕首・十六串呂
9 Negi's Staff ネギの杖 1040424000 3 1 6 97 true false 150 15 38 239 289 273 1684 2037 false false false 2025-02-04 2025-02-04 Negi%27s_Staff ネギの杖 (SSR) 483872 ネギの杖
10 Ensis Exorcizans ハマノツルギ 1040028300 3 6 1 97 true false 150 15 20 135 164 364 2207 2668 false false false 2025-02-04 2025-02-04 Ensis Exorcizans ハマノツルギ (SSR) 483873 ハマノツルギ
11 Fourth Fork of the Fluffle 第四卯行突匙 1040219700 3 4 4 99 true false 150 15 41 248 300 393 2346 2834 false false false 2025-02-14 2025-02-14 Fourth Fork of the Fluffle 第四卯行突匙 (SSR) 485117 第四卯行突匙
12 Clapotis Douleur ドゥルール・クラポティ 1040319200 3 5 3 99 false false 100 10 23 171 484 2731 false false false 2025-02-14 Clapotis Douleur ドゥルール・クラポティ (SSR) 485128 ドゥルール・クラポティ

View file

@ -0,0 +1,3 @@
name_en,name_jp,granblue_id,rarity,element,proficiency1,proficiency2,gender,race1,race2,flb,min_hp,max_hp,max_hp_flb,min_atk,max_atk,max_atk_flb,base_da,base_ta,ougi_ratio,ougi_ratio_flb,special,ulb,max_hp_ulb,max_atk_ulb,character_id,wiki_en,release_date,flb_date,ulb_date,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp
Yuel (Grand),ユエル(リミテッドver),3040580000,3,2,10,8,2,2,,false,265,1375,,1413,7335,,,,,,false,false,,,{3006},Yuel (Grand),2025-02-27,,,ユエル (SSR)リミテッドバージョン,486749,SSRリミテッドユエル,,
Tsukuyomi,ツクヨミ,3040581000,3,5,6,,2,5,,false,335,1175,,1785,6275,,,,,,false,false,,,{3270},Tsukuyomi,2025-02-27,,,ツクヨミ (SSR),486748,SSRツクヨミ,,
1 name_en name_jp granblue_id rarity element proficiency1 proficiency2 gender race1 race2 flb min_hp max_hp max_hp_flb min_atk max_atk max_atk_flb base_da base_ta ougi_ratio ougi_ratio_flb special ulb max_hp_ulb max_atk_ulb character_id wiki_en release_date flb_date ulb_date wiki_ja gamewith kamigame nicknames_en nicknames_jp
2 Yuel (Grand) ユエル(リミテッドver) 3040580000 3 2 10 8 2 2 false 265 1375 1413 7335 false false {3006} Yuel (Grand) 2025-02-27 ユエル (SSR)リミテッドバージョン 486749 SSRリミテッドユエル
3 Tsukuyomi ツクヨミ 3040581000 3 5 6 2 5 false 335 1175 1785 6275 false false {3270} Tsukuyomi 2025-02-27 ツクヨミ (SSR) 486748 SSRツクヨミ

View file

@ -0,0 +1,7 @@
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
Sennen Goji,千年護持,1040917100,3,2,10,1,true,false,150,15,29,244,,,519,3577,,,false,,false,false,,,2025-02-28,2025-02-28,,Sennen Goji,,486774,千年護持,,,false,,,,,,,,,,,,,,
Nightgaze Gate,夜見之門,1040424200,3,5,6,99,false,false,100,10,46,285,,,369,2163,,,false,,false,false,,,2025-02-28,,,Nightgaze Gate,,,夜見之門,,,false,,,,,,,,,,,,,,
Bane of Avidya,無明滅却杵,1040424100,3,6,6,98,false,false,100,10,34,293,,,227,1744,,,false,,false,false,,,2025-02-26,,,Bane of Avidya,無明滅却杵,486167,無明滅却杵,,,false,,,,,,,,,,,,,,
"
Klesha-Cleansing Dharmachakra",破魔輪宝,1030609800,2,6,7,98,false,false,75,10,22,136,,,197,1272,,,false,,false,false,,,2025-02-26,,,"
Klesha-Cleansing Dharmachakra",破魔輪宝,,破魔輪宝,,,false,,,,,,,,,,,,,,
1 name_en name_jp granblue_id rarity element proficiency series flb ulb max_level max_skill_level min_hp max_hp max_hp_flb max_hp_ulb min_atk max_atk max_atk_flb max_atk_ulb extra ax_type limit ax recruits max_awakening_level release_date flb_date ulb_date wiki_en wiki_ja gamewith kamigame nicknames_en nicknames_jp transcendence transcendence_date
2 Sennen Goji 千年護持 1040917100 3 2 10 1 true false 150 15 29 244 519 3577 false false false 2025-02-28 2025-02-28 Sennen Goji 486774 千年護持 false
3 Nightgaze Gate 夜見之門 1040424200 3 5 6 99 false false 100 10 46 285 369 2163 false false false 2025-02-28 Nightgaze Gate 夜見之門 false
4 Bane of Avidya 無明滅却杵 1040424100 3 6 6 98 false false 100 10 34 293 227 1744 false false false 2025-02-26 Bane of Avidya 無明滅却杵 486167 無明滅却杵 false
5 Klesha-Cleansing Dharmachakra 破魔輪宝 1030609800 2 6 7 98 false false 75 10 22 136 197 1272 false false false 2025-02-26 Klesha-Cleansing Dharmachakra 破魔輪宝 破魔輪宝 false

View file

@ -0,0 +1,9 @@
name_en,name_jp,granblue_id,rarity,element,proficiency,series,flb,ulb,max_level,max_skill_level,min_hp,max_hp,max_hp_flb,max_hp_ulb,min_atk,max_atk,max_atk_flb,max_atk_ulb,extra,ax_type,limit,ax,recruits,max_awakening_level,release_date,flb_date,ulb_date,wiki_en,wiki_ja,gamewith,kamigame,nicknames_en,nicknames_jp,transcendence,transcendence_date, , , , , , , , , , , , ,
,,1040619800,,,,,,,,,,,,,,,,,,,,,3040572000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040713900,,,,,,,,,,,,,,,,,,,,,3040573000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040028200,,,,,,,,,,,,,,,,,,,,,3040577000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040121200,,,,,,,,,,,,,,,,,,,,,3040576000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040219700,,,,,,,,,,,,,,,,,,,,,3040579000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040319200,,,,,,,,,,,,,,,,,,,,,3040578000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040917100,,,,,,,,,,,,,,,,,,,,,3040580000,,,,,,,,,,,,,,,,,,,,,,,,,
,,1040424200,,,,,,,,,,,,,,,,,,,,,3040581000,,,,,,,,,,,,,,,,,,,,,,,,,
1 name_en name_jp granblue_id rarity element proficiency series flb ulb max_level max_skill_level min_hp max_hp max_hp_flb max_hp_ulb min_atk max_atk max_atk_flb max_atk_ulb extra ax_type limit ax recruits max_awakening_level release_date flb_date ulb_date wiki_en wiki_ja gamewith kamigame nicknames_en nicknames_jp transcendence transcendence_date
2 1040619800 3040572000
3 1040713900 3040573000
4 1040028200 3040577000
5 1040121200 3040576000
6 1040219700 3040579000
7 1040319200 3040578000
8 1040917100 3040580000
9 1040424200 3040581000

Some files were not shown because too many files have changed in this diff Show more