Compare commits

...

46 commits

Author SHA1 Message Date
ad2d9a1614 Update coordinator.rb
Adds 'queued' as a state for generation
2025-02-09 18:35:59 -08:00
3f325e025a Update character.rb
Add explicit Awakenings enum
2025-02-09 18:35:25 -08:00
52225fe40b Update grid_character.rb
- Adds code transforming incoming ring and awakening values into something the db understands
2025-02-09 18:35:15 -08:00
241393661c Update weapon_awakening.rb
- Removes redefined explicit associations
2025-02-09 18:34:46 -08:00
f1590ab981 Update party.rb
- Removes favorited accessor
- Renames derivative_parties to remixes and adds in-built sort
2025-02-09 18:34:32 -08:00
d7f11e3ca5 Update awakening.rb
- Removes explicitly defined associations and adds ActiveRecord associations instead
2025-02-09 18:34:00 -08:00
9fdce28253 Update users_controller.rb
More efficient way of denoting favorited parties.
2025-02-09 18:33:19 -08:00
f89b21c253 Eager load raids/groups when querying 2025-02-09 18:12:56 -08:00
588d682801 Eager-load jobs when querying job skills 2025-02-09 18:12:38 -08:00
dbe7c67b3d 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.
2025-02-09 18:10:12 -08:00
bb82f74e27 Move n+1 detection to around_action hook 2025-02-09 18:04:59 -08:00
76f3b85613 Update raid blueprints
- Show flat representation of raid group in RaidBlueprint's nested view
- Show nested representation of raid in RaidGroupBlueprint's full view
2025-02-09 18:04:43 -08:00
8f77a1f613 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.
2025-02-09 18:03:35 -08:00
be91c2c033 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.
2025-02-09 18:00:14 -08:00
844d3ee6f8 Maintain API consistency with raid blueprint 2025-02-07 03:38:47 -08:00
ea0bbc542e Alias table name to object to maintain API consistency 2025-02-07 03:34:10 -08:00
b89e83df97 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
2025-02-07 03:27:39 -08:00
234d337af7 Update parties_controller.rb
Adds the rest of the changes, too tired to write them all out. Some preview generation, some filtering
2025-02-07 03:22:47 -08:00
38f6c043bf Update parties_controller.rb
Updates apply_includes and apply_excludes, along with modifying id_to_table and build_query
2025-02-07 03:18:30 -08:00
fde9b08edc Update parties#index 2025-02-07 03:17:36 -08:00
fdda833337 Update parties_controller.rbs 2025-02-07 03:17:04 -08:00
59be9d80d2 Remove redundant return 2025-02-07 02:22:47 -08:00
09dc344e35 Refactor parties#create 2025-02-07 02:21:52 -08:00
b526ce2138 Fix old view name in PartyBlueprint 2025-02-07 02:21:38 -08:00
5ecfeb6684 Update filter condition helpers
Just minor refactoring
2025-02-07 02:17:04 -08:00
59564df5ab Adds a helper method for party privacy 2025-02-07 02:15:48 -08:00
5d8ec96172 Updates excluded methods and calculate_count
* Use `includes` instead of `joins`
* Use a less-insane way of counting
2025-02-07 02:11:50 -08:00
8194844d28 Update logic and logs 2025-02-07 02:06:40 -08:00
ab1fd78530 Create parties_controller.rbs 2025-02-07 02:06:14 -08:00
1bc7d5fac9 Add method comments 2025-02-07 02:02:44 -08:00
9f86f3bf29 Remove preview expiry constants
These are defined in the Coordinator instead
2025-02-07 01:59:53 -08:00
adbb048867 Refactored PartyBlueprint to minimize N+1s 2025-02-07 01:58:52 -08:00
cca4427a75 Add cache to is_favorited(user) 2025-02-07 01:58:29 -08:00
dc24f4e600 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?`
2025-02-07 01:58:17 -08:00
bd53af4339 Add counter caches to party 2025-02-07 01:54:27 -08:00
b86f3a90f6 Remove N+1 from grid object models
Reimplementing `character` `summon` and `weapon` was making N+1s which made queries really slow
2025-02-07 01:53:21 -08:00
4afccabaaf Refactor grid object blueprints 2025-02-07 01:51:34 -08:00
b71ca8e458 Refactor canonical object blueprints 2025-02-07 01:51:24 -08:00
e7db082d8b Update api_controller.rb
Add N+1 detectioin via Prosopite in development/test environments
2025-02-07 01:50:58 -08:00
0ad2db92fa Enable query logging 2025-02-07 01:35:30 -08:00
6dcb15f10d Configure Prosopite and remove CacheFreeLogger 2025-02-07 01:35:14 -08:00
2979ffb1e5 Configure Sidekiq
Create job for cleaning up party previews
2025-02-07 01:34:55 -08:00
8da912a2e4 Update Gemfile
Production:
- `newrelic_rpm`

Development:
- `pg_query`
- `prosopite`
2025-02-07 01:34:11 -08:00
58fd5db844 Update .gitignore 2025-02-07 01:31:11 -08:00
f914c1f16e Add migrations
* Update preview state default to pending
* Adds indexes
* Adds PgHero and PgStatements
2025-02-07 01:30:08 -08:00
8409fcabcf Update gitignore
There is a mystery postgres folder and we are going to ignore it
2025-02-07 01:29:17 -08:00
40 changed files with 1301 additions and 540 deletions

6
.gitignore vendored
View file

@ -7,6 +7,9 @@
# Ignore bundler config.
/.bundle
# Ignore mystery Postgres folder
/postgres
# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal
@ -46,3 +49,6 @@ config/application.yml
.vscode/*
/config/credentials/production.key
# Ignore AI Codebase-generated files
codebase.md

View file

@ -2,7 +2,6 @@ source 'https://rubygems.org'
ruby '3.3.7'
gem 'bootsnap'
gem 'pg'
gem 'rack-cors'
gem 'rails'
gem 'sprockets-rails'
@ -10,6 +9,9 @@ gem 'sprockets-rails'
# A Ruby Web Server Built For Concurrency
gem 'puma'
# Pg is the Ruby interface to the PostgreSQL RDBMS
gem 'pg'
# A sophisticated and secure hash algorithm for
# hashing passwords.
gem 'bcrypt'
@ -71,6 +73,9 @@ gem 'httparty'
# StringScanner provides for lexical scanning operations on a String.
gem 'strscan'
# New Relic Ruby Agent
gem 'newrelic_rpm'
group :doc do
gem 'apipie-rails'
gem 'sdoc'
@ -88,6 +93,8 @@ end
group :development do
gem 'listen'
gem 'pg_query'
gem 'prosopite'
gem 'solargraph'
gem 'spring'
gem 'spring-commands-rspec'

View file

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

View file

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

View file

@ -3,57 +3,64 @@
module Api
module V1
class GridCharacterBlueprint < ApiBlueprint
view :uncap do
association :party, blueprint: PartyBlueprint, view: :minimal
fields :position, :uncap_level
fields :position, :uncap_level, :perpetuity
field :transcendence_step, if: ->(_field, gc, _options) { gc.character&.ulb } do |gc|
gc.transcendence_step
end
view :nested 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
view :preview do
association :character, name: :object, blueprint: CharacterBlueprint
end
view :full do
include_view :nested
association :party, blueprint: PartyBlueprint, view: :minimal
view :nested do
include_view :mastery_bonuses
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
end
view :uncap do
association :party, blueprint: PartyBlueprint
fields :position, :uncap_level
end
view :destroyed do
fields :position, :created_at, :updated_at
end
view :mastery_bonuses do
field :awakening, if: ->(_field_name, gc, _options) { gc.association(:awakening).loaded? } do |gc|
{
type: AwakeningBlueprint.render_as_hash(gc.awakening),
level: gc.awakening_level.to_i
}
end
field :over_mastery, if: lambda { |_fn, obj, _opt|
obj.ring1.present? && obj.ring2.present? && !obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
} do |c|
mapped_rings = [c.ring1, c.ring2, c.ring3, c.ring4].each_with_object([]) do |ring, arr|
# Skip if the ring is nil or its modifier is blank.
next if ring.blank? || ring['modifier'].blank?
# Convert the string values to numbers.
mod = ring['modifier'].to_i
# Only include if modifier is non-zero.
next if mod.zero?
arr << { modifier: mod, strength: ring['strength'].to_i }
end
mapped_rings
end
field :aetherial_mastery, if: ->(_fn, obj, _opt) { obj.earring.present? && !obj.earring['modifier'].nil? } do |gc, _options|
{
modifier: gc.earring['modifier'].to_i,
strength: gc.earring['strength'].to_i
}
end
end
end
end
end

View file

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

View file

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

View file

@ -3,105 +3,131 @@
module Api
module V1
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.is_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 :nested_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,
blueprint: GridWeaponBlueprint,
view: :nested
end
view :summons do
association :summons,
blueprint: GridSummonBlueprint,
view: :nested
end
view :characters do
association :characters,
blueprint: GridCharacterBlueprint,
view: :nested
end
view :job_skills do
field :job_skills do |job|
# Metadata views
view :preview_metadata do
field :counts do |party|
{
'0' => !job.skill0.nil? ? JobSkillBlueprint.render_as_hash(job.skill0) : nil,
'1' => !job.skill1.nil? ? JobSkillBlueprint.render_as_hash(job.skill1) : nil,
'2' => !job.skill2.nil? ? JobSkillBlueprint.render_as_hash(job.skill2) : nil,
'3' => !job.skill3.nil? ? JobSkillBlueprint.render_as_hash(job.skill3) : nil
weapons: party.weapons_count,
characters: party.characters_count,
summons: party.summons_count
}
end
end
view :minimal do
fields :name, :element, :shortcode, :favorited, :remix,
:extra, :full_auto, :clear_time, :auto_guard, :auto_summon,
:visibility, :created_at, :updated_at
field :guidebooks do |p|
{
'1' => !p.guidebook1.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook1) : nil,
'2' => !p.guidebook2.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook2) : nil,
'3' => !p.guidebook3.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook3) : nil
}
end
association :raid,
blueprint: RaidBlueprint,
view: :full
association :job,
blueprint: JobBlueprint
association :user,
blueprint: UserBlueprint,
view: :minimal
end
view :jobs do
association :job,
blueprint: JobBlueprint
include_view :job_skills
end
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
view :nested_metadata do
association :source_party,
blueprint: PartyBlueprint,
view: :minimal
view: :minimal,
if: ->(_field_name, party, _options) { party.source_party_id.present? }
# TODO: This should probably be paginated
# Re-added remixes association
association :remixes,
blueprint: PartyBlueprint,
view: :collection
view: :preview
end
view :collection do
include_view :preview
# Job-related views
view :job_metadata do
field :job_skills, cache: true do |party|
{
'0' => party.skill0 ? JobSkillBlueprint.render_as_hash(party.skill0) : nil,
'1' => party.skill1 ? JobSkillBlueprint.render_as_hash(party.skill1) : nil,
'2' => party.skill2 ? JobSkillBlueprint.render_as_hash(party.skill2) : nil,
'3' => party.skill3 ? JobSkillBlueprint.render_as_hash(party.skill3) : nil
}
end
field :guidebooks, cache: true do |party|
{
'1' => party.guidebook1 ? GuidebookBlueprint.render_as_hash(party.guidebook1) : nil,
'2' => party.guidebook2 ? GuidebookBlueprint.render_as_hash(party.guidebook2) : nil,
'3' => party.guidebook3 ? GuidebookBlueprint.render_as_hash(party.guidebook3) : nil
}
end
association :accessory,
blueprint: JobAccessoryBlueprint,
if: ->(_field_name, party, _options) { party.accessory_id.present? }
end
# Created view
view :created do
include_view :full
fields :edit_key
end
# Destroyed view
view :destroyed do
fields :name, :description, :created_at, :updated_at
end

View file

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

View file

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

View file

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

View file

@ -10,10 +10,12 @@ module Api
}
end
# Primary information
fields :granblue_id, :element, :proficiency,
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
:series, :ax, :ax_type
# Uncap information
field :uncap do |w|
{
flb: w.flb,
@ -22,28 +24,39 @@ module Api
}
end
field :hp do |w|
{
min_hp: w.min_hp,
max_hp: w.max_hp,
max_hp_flb: w.max_hp_flb,
max_hp_ulb: w.max_hp_ulb
}
end
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)
view :stats do
field :hp do |w|
{
min_hp: w.min_hp,
max_hp: w.max_hp,
max_hp_flb: w.max_hp_flb,
max_hp_ulb: w.max_hp_ulb
}
end
field :atk do |w|
{
min_atk: w.min_atk,
max_atk: w.max_atk,
max_atk_flb: w.max_atk_flb,
max_atk_ulb: w.max_atk_ulb
}
end
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

View file

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

View file

@ -39,17 +39,22 @@ module Api
end
def update
mastery = {}
%i[ring1 ring2 ring3 ring4 earring awakening].each do |key|
value = character_params.to_h[key]
mastery[key] = value unless value.nil?
permitted = character_params.to_h.deep_symbolize_keys
puts "Permitted:"
ap permitted
# For the new nested structure, assign them to the virtual attributes:
@character.new_rings = permitted[:rings] if permitted[:rings].present?
@character.new_awakening = permitted[:awakening] if permitted[:awakening].present?
# For the rest of the attributes, you can assign them normally.
@character.assign_attributes(permitted.except(:rings, :awakening))
if @character.save
render json: GridCharacterBlueprint.render(@character, view: :nested)
else
render_validation_error_response(@character)
end
@character.attributes = character_params.merge(mastery)
return render json: GridCharacterBlueprint.render(@character, view: :full) if @character.save
render_validation_error_response(@character)
end
def resolve
@ -123,7 +128,7 @@ module Api
end
def set
@character = GridCharacter.find(params[:id])
@character = GridCharacter.includes(:awakening).find(params[:id])
end
def find_incoming_character
@ -143,14 +148,59 @@ module Api
render_unauthorized_response if unauthorized_create || unauthorized_update
end
def transform_character_params(raw_params)
# Convert to a symbolized hash for convenience.
raw = raw_params.deep_symbolize_keys
# Only update keys that were provided.
transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity)
transformed[:uncap_level] = raw[:uncap_level].to_i if raw[:uncap_level].present?
transformed[:transcendence_step] = raw[:transcendence_step].to_i if raw[:transcendence_step].present?
# Process rings if provided.
transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present?
# Process earring if provided.
transformed[:earring] = raw[:earring] if raw[:earring].present?
# Process awakening if provided.
if raw[:awakening].present?
transformed[:awakening_id] = raw[:awakening][:id]
# Default to 1 if level is missing (to satisfy validations)
transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level].to_i : 1
end
transformed
end
def transform_rings(rings)
default_ring = { modifier: nil, strength: nil }
# Ensure rings is an array of hashes.
rings_array = Array(rings).map(&:to_h)
# Pad the array to exactly four rings if needed.
rings_array.fill(default_ring, rings_array.size...4)
{
ring1: rings_array[0],
ring2: rings_array[1],
ring3: rings_array[2],
ring4: rings_array[3]
}
end
# Specify whitelisted properties that can be modified.
def character_params
params.require(:character).permit(:id, :party_id, :character_id, :position,
:uncap_level, :transcendence_step, :perpetuity,
:awakening_id, :awakening_level,
ring1: %i[modifier strength], ring2: %i[modifier strength],
ring3: %i[modifier strength], ring4: %i[modifier strength],
earring: %i[modifier strength])
params.require(:character).permit(
:id,
:party_id,
:character_id,
:position,
:uncap_level,
:transcendence_step,
:perpetuity,
awakening: %i[id level],
rings: %i[modifier strength],
earring: %i[modifier strength]
)
end
def resolve_params

View file

@ -4,11 +4,13 @@ module Api
module V1
class JobSkillsController < Api::V1::ApiController
def all
render json: JobSkillBlueprint.render(JobSkill.all)
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
end
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)
end
end

View file

@ -2,40 +2,65 @@
module Api
module V1
# Controller for managing party-related operations in the API
# @api public
class PartiesController < Api::V1::ApiController
before_action :set_from_slug,
except: %w[create destroy update index favorites]
before_action :set, only: %w[update destroy]
before_action :authorize, only: %w[update destroy]
# == Constants
# Maximum number of characters allowed in a party
MAX_CHARACTERS = 5
# Maximum number of summons allowed in a party
MAX_SUMMONS = 8
# Maximum number of weapons allowed in a party
MAX_WEAPONS = 13
# Default minimum number of characters required for filtering
DEFAULT_MIN_CHARACTERS = 3
# Default minimum number of summons required for filtering
DEFAULT_MIN_SUMMONS = 2
# Default minimum number of weapons required for filtering
DEFAULT_MIN_WEAPONS = 5
# Default maximum clear time in seconds
DEFAULT_MAX_CLEAR_TIME = 5400
# == Primary CRUD Actions
# Creates a new party with optional user association
# @return [void]
def create
party = Party.new
# Build the party with the provided parameters and assign the user
party = Party.new(party_params)
party.user = current_user if current_user
party.attributes = party_params if party_params
if party_params && party_params[:raid_id]
raid = Raid.find_by(id: party_params[:raid_id])
party.extra = raid.group.extra
# If a raid_id is given, look it up and assign the extra flag from its group.
if party_params && party_params[:raid_id].present?
if (raid = Raid.find_by(id: party_params[:raid_id]))
party.extra = raid.group.extra
end
end
if party.save!
return render json: PartyBlueprint.render(party, view: :created, root: :party),
status: :created
# Save and render the party, triggering preview generation if the party is ready
if party.save
party.schedule_preview_generation if party.ready_for_preview?
render json: PartyBlueprint.render(party, view: :created, root: :party),
status: :created
else
render_validation_error_response(party)
end
render_validation_error_response(@party)
end
# Shows a specific party if the user has permission to view it
# @return [void]
def show
# If a party is private, check that the user is the owner or an admin
if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode)
@ -47,6 +72,8 @@ module Api
render_not_found_response('project')
end
# Updates an existing party's attributes
# @return [void]
def update
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
@ -62,10 +89,16 @@ module Api
render_validation_error_response(@party)
end
# Deletes a party if the user has permission
# @return [void]
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
# == Extended Party Actions
# Creates a copy of an existing party with attribution
# @return [void]
def remix
new_party = @party.amoeba_dup
new_party.attributes = {
@ -78,6 +111,8 @@ module Api
new_party.local_id = party_params[:local_id] unless party_params.nil?
if new_party.save
# Remixed parties should have content, so generate preview
new_party.schedule_preview_generation
render json: PartyBlueprint.render(new_party, view: :created, root: :party),
status: :created
else
@ -85,37 +120,30 @@ module Api
end
end
# Lists parties based on various filter criteria
# @return [void]
def index
conditions = build_filters
query = build_query(conditions)
query = apply_includes(query, params[:includes]) if params[:includes].present?
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
@parties = fetch_parties(query)
count = calculate_count(query)
total_pages = calculate_total_pages(count)
render_party_json(@parties, count, total_pages)
query = build_parties_query
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
render_paginated_parties(@parties)
end
# Lists parties favorited by the current user
# @return [void]
def favorites
raise Api::V1::UnauthorizedError unless current_user
conditions = build_filters
conditions[:favorites] = { user_id: current_user.id }
query = build_query(conditions, favorites: true)
query = apply_includes(query, params[:includes]) if params[:includes].present?
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
@parties = fetch_parties(query)
count = calculate_count(query)
total_pages = calculate_total_pages(count)
render_party_json(@parties, count, total_pages)
query = build_parties_query(favorites: true)
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
# Mark each party as favorited (if needed)
@parties.each { |party| party.favorited = true }
render_paginated_parties(@parties)
end
# == Preview Management
# Serves the party's preview image
# @return [void]
def preview
coordinator = PreviewService::Coordinator.new(@party)
@ -154,6 +182,19 @@ module Api
end
end
# Returns the current status of a party's preview
# @return [void]
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 a party's preview image
# @return [void]
def regenerate_preview
party = Party.find_by!(shortcode: params[:id])
@ -173,12 +214,67 @@ module Api
private
# Builds the base query for parties, optionally including favorites-specific conditions.
def build_parties_query(favorites: false)
query = Party.includes(
{ raid: :group },
:job,
:user,
:skill0,
:skill1,
:skill2,
:skill3,
:guidebook1,
:guidebook2,
:guidebook3,
{ characters: :character },
{ weapons: :weapon },
{ summons: :summon }
)
# Add favorites join and condition if favorites is true.
if favorites
query = query.joins(:favorites)
.where(favorites: { user_id: current_user.id })
.distinct
query = query.order(created_at: :desc)
else
query = query.order(visibility: :asc, created_at: :desc)
end
query = apply_filters(query)
query = apply_privacy_settings(query)
query = apply_includes(query, params[:includes]) if params[:includes].present?
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
query
end
# Renders the paginated parties with blueprint and meta data.
def render_paginated_parties(parties)
render json: PartyBlueprint.render(
parties,
view: :preview,
root: :results,
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: COLLECTION_PER_PAGE
},
current_user: current_user
)
end
# == Authorization Helpers
# Checks if the current user is authorized to modify the party
# @return [void]
def authorize
return unless not_owner && !admin_mode
render_unauthorized_response
end
# Determines if the current user is not the owner of the party
# @return [Boolean]
def not_owner
if @party.user
# party has a user and current_user does not match
@ -197,54 +293,139 @@ module Api
false
end
def build_filters
params = request.params
# == Preview Generation
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? }
# Schedules a background job to generate the party preview
# @return [void]
def schedule_preview_generation
GeneratePartyPreviewJob.perform_later(id)
end
# == Query Building Helpers
def apply_filters(query)
conditions = build_filters
# Use the compound indexes effectively
query = query.where(conditions)
.where(name_quality) if params[:name_quality].present?
# Use the counters index
query = query.where(
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS,
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS
)
query
end
def apply_privacy_settings(query)
return query if admin_mode
if params[:favorites].present?
query.where('visibility < 3')
else
query.where(visibility: 1)
end
end
# Builds filter conditions from request parameters
# @return [Hash] conditions for the query
def build_filters
{
element: params[:element].present? ? params[:element].to_i : nil,
raid_id: params[:raid],
created_at: build_date_range,
full_auto: build_option(params[:full_auto]),
auto_guard: build_option(params[:auto_guard]),
charge_attack: build_option(params[:charge_attack]),
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS,
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS
}.compact
end
def build_date_range
return nil unless params[:recency].present?
start_time = DateTime.current - params[:recency].to_i.seconds
start_time.beginning_of_day..DateTime.current
end
# Paginates the given query of parties and marks favorites for the current user
#
# @param query [ActiveRecord::Relation] The base query containing parties
# @param page [Integer, nil] The page number for pagination (defaults to `params[:page]`)
# @param per_page [Integer] The number of records per page (defaults to `COLLECTION_PER_PAGE`)
# @return [ActiveRecord::Relation] The paginated and processed list of parties
#
# This method orders parties by creation date in descending order, applies pagination,
# and marks each party as favorited if the current user has favorited it.
def paginate_parties(query, page: nil, per_page: COLLECTION_PER_PAGE)
query.order(created_at: :desc)
.paginate(page: page || params[:page], per_page: per_page)
.tap do |parties|
if current_user
parties.each { |party| party.favorited = party.is_favorited(current_user) }
end
end
end
# == Parameter Processing Helpers
# Converts start time parameter for filtering
# @param recency [String, nil] time period in seconds
# @return [DateTime, nil] calculated start time
def build_start_time(recency)
return unless recency.present?
(DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day
end
# Builds count parameter with default fallback
# @param value [String, nil] count value
# @param default [Integer] default value
# @return [Integer] processed count
def build_count(value, default)
value.blank? ? default : value.to_i
end
# Processes maximum clear time parameter
# @param value [String, nil] clear time value in seconds
# @return [Integer] processed maximum clear time
def build_max_clear_time(value)
value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i
end
# Processes element parameter
# @param element [String, nil] element identifier
# @return [Integer, nil] processed element value
def build_element(element)
element.to_i unless element.blank?
end
# Processes boolean option parameters
# @param value [String, nil] option value
# @return [Integer, nil] processed option value
def build_option(value)
value.to_i unless value.blank? || value.to_i == -1
end
# == Query Building Helpers
# Constructs the main query for party filtering
# @param conditions [Hash] filter conditions
# @param favorites [Boolean] whether to include favorites
# @return [ActiveRecord::Relation] constructed query
def build_query(conditions, favorites: false)
query = Party.distinct
.joins(weapons: [:object], summons: [:object], characters: [:object])
# joins vs includes? -> reduces n+1s
.preload(
weapons: { object: %i[name_en name_jp granblue_id element] },
summons: { object: %i[name_en name_jp granblue_id element] },
characters: { object: %i[name_en name_jp granblue_id element] }
)
.group('parties.id')
.where(conditions)
.where(privacy(favorites: favorites))
@ -252,104 +433,158 @@ module Api
.where(user_quality)
.where(original)
query = query.joins(:favorites) if favorites
query = query.includes(: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
# Applies the include conditions to query
# @param query [ActiveRecord::Relation] base query
# @param includes [String] comma-separated list of IDs to include
# @return [ActiveRecord::Relation] modified query
def apply_includes(query, includes)
included = includes.split(',')
includes_condition = included.map { |id| includes(id) }.join(' AND ')
query.where(includes_condition)
return query unless includes.present?
includes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && object_table
# Build a subquery that joins the grid table to the object table.
condition = <<-SQL.squish
EXISTS (
SELECT 1
FROM #{grid_table}
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
WHERE #{object_table}.granblue_id = ?
AND #{grid_table}.party_id = parties.id
)
SQL
query = query.where(condition, id)
end
query
end
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
# Applies the exclude conditions to query
# @param query [ActiveRecord::Relation] base query
# @return [ActiveRecord::Relation] modified query
def apply_excludes(query, excludes)
return query unless excludes.present?
query.where(characters_subquery.exists.not)
.where(weapons_subquery.exists.not)
.where(summons_subquery.exists.not)
excludes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && object_table
condition = <<-SQL.squish
NOT EXISTS (
SELECT 1
FROM #{grid_table}
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
WHERE #{object_table}.granblue_id = ?
AND #{grid_table}.party_id = parties.id
)
SQL
query = query.where(condition, id)
end
query
end
# == Query Filtering Helpers
# Generates subquery for excluded characters
# @return [ActiveRecord::Relation, nil] exclusion query
def excluded_characters
return unless params[:excludes]
excluded = params[:excludes].split(',').filter { |id| id[0] == '3' }
GridCharacter.joins(:object)
GridCharacter.includes(:object)
.where(characters: { granblue_id: excluded })
.where('grid_characters.party_id = parties.id')
end
# Generates subquery for excluded summons
# @return [ActiveRecord::Relation, nil] exclusion query
def excluded_summons
return unless params[:excludes]
excluded = params[:excludes].split(',').filter { |id| id[0] == '2' }
GridSummon.joins(:object)
GridSummon.includes(:object)
.where(summons: { granblue_id: excluded })
.where('grid_summons.party_id = parties.id')
end
# Generates subquery for excluded weapons
# @return [ActiveRecord::Relation, nil] exclusion query
def excluded_weapons
return unless params[:excludes]
excluded = params[:excludes].split(',').filter { |id| id[0] == '1' }
GridWeapon.joins(:object)
GridWeapon.includes(:object)
.where(weapons: { granblue_id: excluded })
.where('grid_weapons.party_id = parties.id')
end
# == Query Processing
# Fetches and processes parties query with pagination
# @param query [ActiveRecord::Relation] base query
# @return [ActiveRecord::Relation] processed and paginated parties
def fetch_parties(query)
query.order(created_at: :desc)
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
.each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false }
end
# Calculates total count for pagination
# @param query [ActiveRecord::Relation] current query
# @return [Integer] total count
def calculate_count(query)
query.count.values.sum
# query.count.values.sum
query.count
end
# Calculates total pages for pagination
# @param count [Integer] total record count
# @return [Integer] total pages
def calculate_total_pages(count)
count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
# count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
(count.to_f / COLLECTION_PER_PAGE).ceil
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
})
# == Include/Exclude Processing
# Generates SQL for including specific items
# @param id [String] item identifier
# @return [String] SQL condition
def includes(id)
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
end
def privacy(favorites: false)
return if admin_mode
if favorites
'visibility < 3'
else
'visibility = 1'
end
# Generates SQL for excluding specific items
# @param id [String] item identifier
# @return [String] SQL condition
def excludes(id)
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
end
# == Filter Condition Helpers
# Generates user quality condition
# @return [String, nil] SQL condition for user quality
def user_quality
return if request.params[:user_quality].blank? || request.params[:user_quality] == 'false'
return if params[:user_quality].blank? || params[:user_quality] == 'false'
'user_id IS NOT NULL'
end
# Generates name quality condition
# @return [String, nil] SQL condition for name quality
def name_quality
return if params[:name_quality].blank? || params[:name_quality] == 'false'
low_quality = [
'Untitled',
'Remix of Untitled',
@ -364,33 +599,54 @@ module Api
'無題のリミックスのリミックスのリミックスのリミックス',
'無題のリミックスのリミックスのリミックスのリミックスのリミックス'
]
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
# Generates original party condition
# @return [String, nil] SQL condition for original parties
def original
return if request.params['original'].blank? || request.params['original'] == 'false'
return if params['original'].blank? || 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
# == Filter Condition Helpers
table
# Generates privacy condition based on favorites
# @param favorites [Boolean] whether viewing favorites
# @return [String, nil] SQL condition
def privacy(favorites: false)
return if admin_mode
if favorites
'visibility < 3'
else
'visibility = 1'
end
end
# == Utility Methods
# Maps ID prefixes to table names
# @param id [String] item identifier
# @return [Array(String, String)] corresponding table name
def grid_table_and_object_table(id)
case id[0]
when '3'
%w[grid_characters characters]
when '2'
%w[grid_summons summons]
when '1'
%w[grid_weapons weapons]
else
[nil, nil]
end
end
# Generates name for remixed party
# @param name [String] original party name
# @return [String] generated remix name
def remixed_name(name)
blanked_name = {
en: name.blank? ? 'Untitled team' : name,
@ -409,19 +665,54 @@ module Api
end
end
# == Party Loading
# Loads party by shortcode for routes using :id
# @return [void]
def set_from_slug
@party = Party.where('shortcode = ?', params[:id]).first
if @party
@party.favorited = current_user && @party ? @party.is_favorited(current_user) : false
else
render_not_found_response('party')
end
@party = Party.includes(
:user,
:job,
{ raid: :group },
{ characters: [:character, :awakening] },
{
weapons: {
# Eager load the associated weapon and its awakenings.
weapon: [:awakenings],
# Eager load the grid weapons own awakening (if applicable).
awakening: {},
# Eager load any weapon key associations.
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
# Loads party by ID for update/destroy actions
# @return [void]
def set
@party = Party.where('id = ?', params[:id]).first
end
# == Parameter Sanitization
# Sanitizes and permits party parameters
# @return [Hash, nil] permitted parameters
def party_params
return unless params[:party].present?

View file

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

View file

@ -58,17 +58,19 @@ module Api
conditions = build_conditions
conditions[:user_id] = @user.id
parties = Party
.where(conditions)
.where(name_quality)
.where(user_quality)
.where(original)
.where(privacy)
.order(created_at: :desc)
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
.each do |party|
party.favorited = current_user ? party.is_favorited(current_user) : false
end
favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited"
parties = Party.where(conditions)
.where(name_quality)
.where(user_quality)
.where(original)
.where(privacy)
.includes(:favorites)
.select(Party.arel_table[Arel.star])
.select(
Arel.sql(favorites_query)
)
.order(created_at: :desc)
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
count = Party.where(conditions).count
@ -101,7 +103,7 @@ module Api
unless params['recency'].blank?
start_time = (DateTime.current - params['recency'].to_i.seconds)
.to_datetime.beginning_of_day
.to_datetime.beginning_of_day
end
min_characters_count = params['characters_count'].blank? ? DEFAULT_MIN_CHARACTERS : params['characters_count'].to_i

View file

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

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class GridCharacter < ApplicationRecord
has_one :object, class_name: 'Character', foreign_key: :id, primary_key: :character_id
belongs_to :character, foreign_key: :character_id, primary_key: :id
belongs_to :awakening, optional: true
belongs_to :party,
@ -15,6 +15,12 @@ class GridCharacter < ApplicationRecord
validate :validate_aetherial_mastery_value, on: :update
validate :over_mastery_attack_matches_hp, on: :update
# Virtual attribute for the new rings structure
attr_accessor :new_rings
# Virtual attribute for the new awakening structure
attr_accessor :new_awakening
##### Amoeba configuration
amoeba do
set ring1: { modifier: nil, strength: nil }
@ -25,6 +31,9 @@ class GridCharacter < ApplicationRecord
set perpetuity: false
end
before_validation :apply_new_rings, if: -> { new_rings.present? }
before_validation :apply_new_awakening, if: -> { new_awakening.present? }
# Add awakening before the model saves
before_save :add_awakening
@ -78,10 +87,6 @@ class GridCharacter < ApplicationRecord
'aetherial_mastery')
end
def character
Character.find(character_id)
end
def blueprint
GridCharacterBlueprint
end
@ -94,6 +99,24 @@ class GridCharacter < ApplicationRecord
self.awakening = Awakening.where(slug: 'character-balanced').sole
end
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
def apply_new_awakening
self.awakening_id = new_awakening[:id]
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
end
def check_value(property, type)
# Input format
# { ring1: { atk: 300 } }

View file

@ -1,19 +1,16 @@
# frozen_string_literal: true
class GridSummon < ApplicationRecord
belongs_to :summon, foreign_key: :summon_id, primary_key: :id
belongs_to :party,
counter_cache: :summons_count,
inverse_of: :summons
validates_presence_of :party
has_one :object, class_name: 'Summon', foreign_key: :id, primary_key: :summon_id
validate :compatible_with_position, on: :create
validate :no_conflicts, on: :create
def summon
Summon.find(summon_id)
end
def blueprint
GridSummonBlueprint
end

View file

@ -1,13 +1,13 @@
# frozen_string_literal: true
class GridWeapon < ApplicationRecord
belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id
belongs_to :party,
counter_cache: :weapons_count,
inverse_of: :weapons
validates_presence_of :party
has_one :object, class_name: 'Weapon', foreign_key: :id, primary_key: :weapon_id
belongs_to :weapon_key1, class_name: 'WeaponKey', foreign_key: :weapon_key1_id, optional: true
belongs_to :weapon_key2, class_name: 'WeaponKey', foreign_key: :weapon_key2_id, optional: true
belongs_to :weapon_key3, class_name: 'WeaponKey', foreign_key: :weapon_key3_id, optional: true
@ -33,10 +33,6 @@ class GridWeapon < ApplicationRecord
GridWeaponBlueprint
end
def weapon
Weapon.find(weapon_id)
end
def weapon_keys
[weapon_key1, weapon_key2, weapon_key3].compact
end

View file

@ -7,9 +7,9 @@ class Party < ApplicationRecord
foreign_key: :source_party_id,
optional: true
has_many :derivative_parties,
has_many :remixes, -> { order(created_at: :desc) },
class_name: 'Party',
foreign_key: :source_party_id,
foreign_key: 'source_party_id',
inverse_of: :source_party,
dependent: :nullify
@ -60,18 +60,21 @@ class Party < ApplicationRecord
has_many :characters,
foreign_key: 'party_id',
class_name: 'GridCharacter',
counter_cache: true,
dependent: :destroy,
inverse_of: :party
has_many :weapons,
foreign_key: 'party_id',
class_name: 'GridWeapon',
counter_cache: true,
dependent: :destroy,
inverse_of: :party
has_many :summons,
foreign_key: 'party_id',
class_name: 'GridSummon',
counter_cache: true,
dependent: :destroy,
inverse_of: :party
@ -103,8 +106,6 @@ class Party < ApplicationRecord
validate :skills_are_unique
validate :guidebooks_are_unique
attr_accessor :favorited
self.enum :preview_state, {
pending: 0,
queued: 1,
@ -113,11 +114,7 @@ class Party < ApplicationRecord
failed: 4
}
after_commit :schedule_preview_regeneration, if: :preview_relevant_changes?
def is_favorited(user)
user.favorite_parties.include? self if user
end
after_commit :schedule_preview_generation, if: :should_generate_preview?
def is_remix
!source_party.nil?
@ -143,6 +140,58 @@ class Party < ApplicationRecord
visibility == 3
end
def is_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
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
def should_generate_preview?
return false unless ready_for_preview?
# Always generate if no preview exists
return true if preview_state.nil? || preview_state == 'pending'
# Generate if failed and enough time has passed for conditions to change
return true if preview_state == 'failed' && preview_generated_at < 5.minutes.ago
# Generate if preview is old
return true if preview_state == 'generated' && preview_expired?
# Only regenerate on content changes if the last generation was > 5 minutes ago
# This prevents rapid regeneration during party building
if preview_content_changed?
return true if preview_generated_at.nil? || preview_generated_at < 5.minutes.ago
end
false
end
def preview_expired?
preview_generated_at.nil? ||
preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago
end
def preview_content_changed?
saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) }
end
def schedule_preview_generation
return if preview_state == 'queued' || preview_state == 'in_progress'
update_column(:preview_state, 'queued')
GeneratePartyPreviewJob.perform_later(id)
end
private
def set_shortcode
@ -188,17 +237,10 @@ class Party < ApplicationRecord
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)
def preview_relevant_attributes
%w[
name job_id element weapons_count characters_count summons_count
full_auto auto_guard charge_attack clear_time
]
end
end

View file

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

View file

@ -8,6 +8,9 @@ module PreviewService
GENERATION_TIMEOUT = 5.minutes
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
PREVIEW_EXPIRY = 30.days
# Public Interface - Core Operations
# Initialize the party preview service
@ -40,59 +43,59 @@ module PreviewService
return false unless should_generate?
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)
set_generation_in_progress
Rails.logger.info("Checking ImageMagick installation...")
Rails.logger.info("🖼️ Checking ImageMagick installation...")
begin
version = `convert -version`
Rails.logger.info("ImageMagick version: #{version}")
Rails.logger.info("🖼️ ImageMagick version: #{version}")
rescue => e
Rails.logger.error("Failed to get ImageMagick version: #{e.message}")
Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}")
end
Rails.logger.info("Creating preview image...")
Rails.logger.info("🖼️ Creating preview image...")
begin
image = create_preview_image
Rails.logger.info("Preview image created successfully")
Rails.logger.info("🖼️ Preview image created successfully")
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"))
raise e
end
Rails.logger.info("Saving preview...")
Rails.logger.info("🖼️ Saving preview...")
begin
save_preview(image)
Rails.logger.info("Preview saved successfully")
Rails.logger.info("🖼️ Preview saved successfully")
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"))
raise e
end
Rails.logger.info("Updating party state...")
Rails.logger.info("🖼️ Updating party state...")
@party.update!(
preview_state: :generated,
preview_generated_at: Time.current
)
Rails.logger.info("Party state updated successfully")
Rails.logger.info("🖼️ Party state updated successfully")
true
rescue => e
Rails.logger.error("Preview generation failed: #{e.class} - #{e.message}")
Rails.logger.error("Stack trace:")
Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}")
Rails.logger.error("🖼️ Stack trace:")
Rails.logger.error(e.backtrace.join("\n"))
handle_preview_generation_error(e)
false
ensure
Rails.logger.info("Cleaning up resources...")
Rails.logger.info("🖼️ Cleaning up resources...")
@image_fetcher.cleanup
clear_generation_in_progress
Rails.logger.info("Cleanup completed")
Rails.logger.info("🖼️ Cleanup completed")
end
end
@ -128,32 +131,48 @@ module PreviewService
#
# @return [Boolean] True if a new preview should be generated, false otherwise
def should_generate?
Rails.logger.info("Checking should_generate? conditions")
Rails.logger.info("🖼️ Checking should_generate? conditions")
if generation_in_progress?
Rails.logger.info("Generation already in progress, returning false")
unless @party.ready_for_preview?
Rails.logger.info("🖼️ Party not ready for preview (insufficient content)")
return false
end
Rails.logger.info("Preview state: #{@party.preview_state}")
# Add 'queued' to the list of valid states for generation
if @party.preview_state.in?(['pending', 'failed', 'queued'])
Rails.logger.info("Preview state is #{@party.preview_state}, returning true")
return true
if generation_in_progress?
Rails.logger.info("🖼️ Generation already in progress, returning false")
return false
end
if @party.preview_state == 'generated'
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("🖼️ Preview state: #{@party.preview_state}")
Rails.logger.info("No conditions met, returning false")
false
case @party.preview_state
when 'pending', 'queued'
Rails.logger.info("🖼️ State is #{@party.preview_state}, will generate")
true
when 'in_progress'
Rails.logger.info("🖼️ State is in_progress, skipping generation")
false
when 'failed'
should_retry = @party.preview_generated_at.nil? ||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
Rails.logger.info("🖼️ Failed state, should retry: #{should_retry}")
should_retry
when 'generated'
expired = @party.preview_expired?
changed = @party.preview_content_changed?
debounced = @party.preview_generated_at.nil? ||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
should_regenerate = expired || (changed && debounced)
Rails.logger.info("🖼️ Generated state check - expired: #{expired}, content changed: #{changed}, debounced: #{debounced}")
Rails.logger.info("🖼️ Should regenerate: #{should_regenerate}")
should_regenerate
else
Rails.logger.info("🖼️ Unknown state, will generate")
true
end
end
# Checks if a preview generation is currently in progress
@ -480,6 +499,12 @@ module PreviewService
)
end
def self.cleanup_stalled_jobs
Party.where(preview_state: :in_progress)
.where('updated_at < ?', 10.minutes.ago)
.update_all(preview_state: :pending)
end
# Deletes local preview files
#
# @return [void]
@ -498,8 +523,12 @@ module PreviewService
def handle_preview_generation_error(error)
Rails.logger.error("Preview generation failed for party #{@party.id}")
Rails.logger.error("Error: #{error.class} - #{error.message}")
Rails.logger.error("Backtrace:\n#{error.backtrace.join("\n")}")
@party.update!(preview_state: :failed)
Rails.logger.error(error.backtrace.join("\n"))
@party.update_columns(
preview_state: 'failed',
preview_generated_at: Time.current
)
end
end
end

View file

@ -29,6 +29,11 @@ module HenseiApi
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
# API-only application configuration
config.api_only = true
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 -
# both very helpful in debugging multi-user production applications.
config.log_tags = [:request_id]
config.after_initialize do
Prosopite.rails_logger = true
end
end

View file

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

View file

@ -1,14 +1,12 @@
# Fetch environment variables with defaults if not set
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost')
redis_port = ENV.fetch('REDISPORT', '6379')
# Combine URL and port (adjust the path/DB as needed)
full_redis_url = "#{redis_url}/0"
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
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
Sidekiq.configure_client do |config|
config.redis = { url: full_redis_url }
config.redis = { url: redis_url }
end

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,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

@ -10,13 +10,13 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
enable_extension "pgcrypto"
enable_extension "uuid-ossp"
create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t|
t.string "update_type", null: false
@ -67,6 +67,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.string "kamigame", default: ""
t.string "nicknames_en", default: [], null: false, array: true
t.string "nicknames_jp", default: [], null: false, array: true
t.index ["granblue_id"], name: "index_characters_on_granblue_id"
t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin
end
@ -129,6 +130,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "awakening_level", default: 1
t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id"
t.index ["character_id"], name: "index_grid_characters_on_character_id"
t.index ["party_id", "position"], name: "index_grid_characters_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_characters_on_party_id"
end
@ -143,6 +145,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.datetime "updated_at", null: false
t.integer "transcendence_step", default: 0, null: false
t.boolean "quick_summon", default: false
t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_summons_on_party_id"
t.index ["summon_id"], name: "index_grid_summons_on_summon_id"
end
@ -168,6 +171,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "transcendence_step", default: 0
t.string "weapon_key4_id"
t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id"
t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position"
t.index ["party_id"], name: "index_grid_weapons_on_party_id"
t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id"
t.index ["weapon_key1_id"], name: "index_grid_weapons_on_weapon_key1_id"
@ -304,18 +308,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.datetime "preview_generated_at"
t.string "preview_s3_key"
t.index ["accessory_id"], name: "index_parties_on_accessory_id"
t.index ["created_at"], name: "index_parties_on_created_at"
t.index ["element"], name: "index_parties_on_element"
t.index ["guidebook1_id"], name: "index_parties_on_guidebook1_id"
t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id"
t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id"
t.index ["job_id"], name: "index_parties_on_job_id"
t.index ["preview_generated_at"], name: "index_parties_on_preview_generated_at"
t.index ["preview_state"], name: "index_parties_on_preview_state"
t.index ["raid_id"], name: "index_parties_on_raid_id"
t.index ["shortcode"], name: "index_parties_on_shortcode"
t.index ["skill0_id"], name: "index_parties_on_skill0_id"
t.index ["skill1_id"], name: "index_parties_on_skill1_id"
t.index ["skill2_id"], name: "index_parties_on_skill2_id"
t.index ["skill3_id"], name: "index_parties_on_skill3_id"
t.index ["source_party_id"], name: "index_parties_on_source_party_id"
t.index ["user_id"], name: "index_parties_on_user_id"
t.index ["visibility", "created_at"], name: "index_parties_on_visibility_created_at"
t.index ["weapons_count", "characters_count", "summons_count"], name: "index_parties_on_counters"
end
create_table "pg_search_documents", force: :cascade do |t|
@ -331,6 +341,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"
end
create_table "pghero_query_stats", force: :cascade do |t|
t.text "database"
t.text "user"
t.text "query"
t.bigint "query_hash"
t.float "total_time"
t.bigint "calls"
t.datetime "captured_at", precision: nil
t.index ["database", "captured_at"], name: "index_pghero_query_stats_on_database_and_captured_at"
end
create_table "raid_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name_en", null: false
t.string "name_jp", null: false
@ -349,7 +370,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.integer "element"
t.string "slug"
t.uuid "group_id"
t.index ["group_id"], name: "index_raids_on_group_id"
end
create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -400,6 +420,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.date "transcendence_date"
t.string "nicknames_en", default: [], null: false, array: true
t.string "nicknames_jp", default: [], null: false, array: true
t.index ["granblue_id"], name: "index_summons_on_granblue_id"
t.index ["name_en"], name: "index_summons_on_name_en", opclass: :gin_trgm_ops, using: :gin
end
@ -474,6 +495,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
t.boolean "transcendence", default: false
t.date "transcendence_date"
t.string "recruits"
t.index ["granblue_id"], name: "index_weapons_on_granblue_id"
t.index ["name_en"], name: "index_weapons_on_name_en", opclass: :gin_trgm_ops, using: :gin
t.index ["recruits"], name: "index_weapons_on_recruits"
end
@ -501,9 +523,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: "skill3_id"
add_foreign_key "parties", "jobs"
add_foreign_key "parties", "parties", column: "source_party_id"
add_foreign_key "parties", "raids"
add_foreign_key "parties", "users"
add_foreign_key "raids", "raid_groups", column: "group_id"
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", "weapons"
end

View file

@ -0,0 +1,111 @@
module Api
module V1
class PartiesController < Api::V1::ApiController
@parties: ActiveRecord::Relation[Party]
MAX_CHARACTERS: Integer
MAX_SUMMONS: Integer
MAX_WEAPONS: Integer
DEFAULT_MIN_CHARACTERS: Integer
DEFAULT_MIN_SUMMONS: Integer
DEFAULT_MIN_WEAPONS: Integer
DEFAULT_MAX_CLEAR_TIME: Integer
def create: () -> void
def show: () -> void
def update: () -> void
def destroy: () -> void
def remix: () -> void
def index: () -> void
def favorites: () -> void
def preview: () -> void
def preview_status: () -> void
def regenerate_preview: () -> void
private
def authorize: () -> void
def grid_table_and_object_table: (String) -> [String?, String?]
def not_owner: () -> bool
def schedule_preview_generation: () -> void
def apply_filters: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
def apply_privacy_settings: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
def apply_includes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party]
def apply_excludes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party]
def build_filters: () -> Hash[Symbol, untyped]
def build_date_range: () -> Range[DateTime]?
def build_start_time: (String?) -> DateTime?
def build_count: (String?, Integer) -> Integer
def build_max_clear_time: (String?) -> Integer
def build_element: (String?) -> Integer?
def build_option: (String?) -> Integer?
def build_query: (Hash[Symbol, untyped], bool) -> ActiveRecord::Relation[Party]
def fetch_parties: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
def calculate_count: (ActiveRecord::Relation[Party]) -> Integer
def calculate_total_pages: (Integer) -> Integer
def paginate_parties: (
ActiveRecord::Relation[Party],
?page: Integer?,
?per_page: Integer
) -> ActiveRecord::Relation[Party]
def excluded_characters: () -> ActiveRecord::Relation[GridCharacter]?
def excluded_summons: () -> ActiveRecord::Relation[GridSummon]?
def excluded_weapons: () -> ActiveRecord::Relation[GridWeapon]?
def render_party_json: (ActiveRecord::Relation[Party]) -> void
def privacy: (?favorites: bool) -> String?
def user_quality: () -> String?
def name_quality: () -> String?
def original: () -> String?
def includes: (String) -> String
def excludes: (String) -> String
def id_to_table: (String) -> String
def remixed_name: (String) -> String
def set_from_slug: () -> void
def set: () -> void
def party_params: () -> Hash[Symbol, untyped]?
end
end
end