hensei-api/app/controllers/api/v1/parties_controller.rb
Justin Edmund 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

762 lines
26 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
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
# Build the party with the provided parameters and assign the user
party = Party.new(party_params)
party.user = current_user if current_user
# If a raid_id is given, look it up and assign the extra flag from its group.
if party_params && party_params[:raid_id].present?
if (raid = Raid.find_by(id: party_params[:raid_id]))
party.extra = raid.group.extra
end
end
# Save and render the party, triggering preview generation if the party is ready
if party.save
party.schedule_preview_generation if party.ready_for_preview?
render json: PartyBlueprint.render(party, view: :created, root: :party),
status: :created
else
render_validation_error_response(party)
end
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)
return render_unauthorized_response
end
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party
render_not_found_response('project')
end
# Updates an existing party's attributes
# @return [void]
def update
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
if party_params && party_params[:raid_id]
raid = Raid.find_by(id: party_params[:raid_id])
@party.extra = raid.group.extra
end
# TODO: Validate accessory with job
return render json: PartyBlueprint.render(@party, view: :full, root: :party) if @party.save
render_validation_error_response(@party)
end
# Deletes a party if the user has permission
# @return [void]
def 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 = {
user: current_user,
name: remixed_name(@party.name),
source_party: @party,
remix: true
}
new_party.local_id = party_params[:local_id] unless party_params.nil?
if new_party.save
# 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
render_validation_error_response(new_party)
end
end
# Lists parties based on various filter criteria
# @return [void]
def index
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
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)
if coordinator.generation_in_progress?
response.headers['Retry-After'] = '2'
default_path = Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png")
send_file default_path,
type: 'image/png',
disposition: 'inline'
return
end
# Try to get the preview or send default
begin
if Rails.env.production?
# Stream S3 content instead of redirecting
s3_object = coordinator.get_s3_object
send_data s3_object.body.read,
filename: "#{@party.shortcode}.png",
type: 'image/png',
disposition: 'inline'
else
# In development, serve from local filesystem
send_file coordinator.local_preview_path,
type: 'image/png',
disposition: 'inline'
end
rescue Aws::S3::Errors::NoSuchKey
# Schedule generation if needed
coordinator.schedule_generation unless coordinator.generation_in_progress?
# Return default preview while generating
send_file Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png"),
type: 'image/png',
disposition: 'inline'
end
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])
# Ensure only party owner can force regeneration
unless current_user && party.user_id == current_user.id
return render_unauthorized_response
end
preview_service = PreviewService::Coordinator.new(party)
if preview_service.force_regenerate
render json: { status: 'Preview regeneration started' }
else
render json: { error: 'Preview regeneration failed' },
status: :unprocessable_entity
end
end
private
# Builds the base query for parties, optionally including favorites-specific conditions.
def build_parties_query(favorites: false)
query = Party.includes(
{ raid: :group },
:job,
:user,
:skill0,
:skill1,
:skill2,
:skill3,
:guidebook1,
:guidebook2,
:guidebook3,
{ characters: :character },
{ weapons: :weapon },
{ summons: :summon }
)
# Add favorites join and condition if favorites is true.
if favorites
query = query.joins(:favorites)
.where(favorites: { user_id: current_user.id })
.distinct
query = query.order(created_at: :desc)
else
query = query.order(visibility: :asc, created_at: :desc)
end
query = apply_filters(query)
query = apply_privacy_settings(query)
query = apply_includes(query, params[:includes]) if params[:includes].present?
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
query
end
# Renders the paginated parties with blueprint and meta data.
def render_paginated_parties(parties)
render json: PartyBlueprint.render(
parties,
view: :preview,
root: :results,
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: COLLECTION_PER_PAGE
},
current_user: current_user
)
end
# == Authorization Helpers
# Checks if the current user is authorized to modify the party
# @return [void]
def authorize
return unless not_owner && !admin_mode
render_unauthorized_response
end
# Determines if the current user is not the owner of the party
# @return [Boolean]
def not_owner
if @party.user
# party has a user and current_user does not match
return true if current_user != @party.user
# party has a user, there's no current_user, but edit_key is provided
return true if current_user.nil? && edit_key
else
# party has no user, there's no current_user and there's no edit_key provided
return true if current_user.nil? && edit_key.nil?
# party has no user, there's no current_user, and the party's edit_key doesn't match the provided edit_key
return true if current_user.nil? && @party.edit_key != edit_key
end
false
end
# == Preview Generation
# Schedules a background job to generate the party preview
# @return [void]
def schedule_preview_generation
GeneratePartyPreviewJob.perform_later(id)
end
# == Query Building Helpers
def apply_filters(query)
conditions = build_filters
# Use the compound indexes effectively
query = query.where(conditions)
.where(name_quality) if params[:name_quality].present?
# Use the counters index
query = query.where(
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS,
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS
)
query
end
def apply_privacy_settings(query)
return query if admin_mode
if params[:favorites].present?
query.where('visibility < 3')
else
query.where(visibility: 1)
end
end
# Builds filter conditions from request parameters
# @return [Hash] conditions for the query
def build_filters
{
element: params[:element].present? ? params[:element].to_i : nil,
raid_id: params[:raid],
created_at: build_date_range,
full_auto: build_option(params[:full_auto]),
auto_guard: build_option(params[:auto_guard]),
charge_attack: build_option(params[:charge_attack]),
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS,
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS
}.compact
end
def build_date_range
return nil unless params[:recency].present?
start_time = DateTime.current - params[:recency].to_i.seconds
start_time.beginning_of_day..DateTime.current
end
# Paginates the given query of parties and marks favorites for the current user
#
# @param query [ActiveRecord::Relation] The base query containing parties
# @param page [Integer, nil] The page number for pagination (defaults to `params[:page]`)
# @param per_page [Integer] The number of records per page (defaults to `COLLECTION_PER_PAGE`)
# @return [ActiveRecord::Relation] The paginated and processed list of parties
#
# This method orders parties by creation date in descending order, applies pagination,
# and marks each party as favorited if the current user has favorited it.
def paginate_parties(query, page: nil, per_page: COLLECTION_PER_PAGE)
query.order(created_at: :desc)
.paginate(page: page || params[:page], per_page: per_page)
.tap do |parties|
if current_user
parties.each { |party| party.favorited = party.is_favorited(current_user) }
end
end
end
# == Parameter Processing Helpers
# Converts start time parameter for filtering
# @param recency [String, nil] time period in seconds
# @return [DateTime, nil] calculated start time
def build_start_time(recency)
return unless recency.present?
(DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day
end
# Builds count parameter with default fallback
# @param value [String, nil] count value
# @param default [Integer] default value
# @return [Integer] processed count
def build_count(value, default)
value.blank? ? default : value.to_i
end
# Processes maximum clear time parameter
# @param value [String, nil] clear time value in seconds
# @return [Integer] processed maximum clear time
def build_max_clear_time(value)
value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i
end
# Processes element parameter
# @param element [String, nil] element identifier
# @return [Integer, nil] processed element value
def build_element(element)
element.to_i unless element.blank?
end
# Processes boolean option parameters
# @param value [String, nil] option value
# @return [Integer, nil] processed option value
def build_option(value)
value.to_i unless value.blank? || value.to_i == -1
end
# == Query Building Helpers
# Constructs the main query for party filtering
# @param conditions [Hash] filter conditions
# @param favorites [Boolean] whether to include favorites
# @return [ActiveRecord::Relation] constructed query
def build_query(conditions, favorites: false)
query = Party.distinct
# joins vs includes? -> reduces n+1s
.preload(
weapons: { object: %i[name_en name_jp granblue_id element] },
summons: { object: %i[name_en name_jp granblue_id element] },
characters: { object: %i[name_en name_jp granblue_id element] }
)
.group('parties.id')
.where(conditions)
.where(privacy(favorites: favorites))
.where(name_quality)
.where(user_quality)
.where(original)
query = query.includes(:favorites) if favorites
query
end
# Applies the include conditions to query
# @param query [ActiveRecord::Relation] base query
# @param includes [String] comma-separated list of IDs to include
# @return [ActiveRecord::Relation] modified query
def apply_includes(query, includes)
return query unless includes.present?
includes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && object_table
# Build a subquery that joins the grid table to the object table.
condition = <<-SQL.squish
EXISTS (
SELECT 1
FROM #{grid_table}
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
WHERE #{object_table}.granblue_id = ?
AND #{grid_table}.party_id = parties.id
)
SQL
query = query.where(condition, id)
end
query
end
# Applies the exclude conditions to query
# @param query [ActiveRecord::Relation] base query
# @return [ActiveRecord::Relation] modified query
def apply_excludes(query, excludes)
return query unless excludes.present?
excludes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && object_table
condition = <<-SQL.squish
NOT EXISTS (
SELECT 1
FROM #{grid_table}
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
WHERE #{object_table}.granblue_id = ?
AND #{grid_table}.party_id = parties.id
)
SQL
query = query.where(condition, id)
end
query
end
# == Query Filtering Helpers
# Generates subquery for excluded characters
# @return [ActiveRecord::Relation, nil] exclusion query
def excluded_characters
return unless params[:excludes]
excluded = params[:excludes].split(',').filter { |id| id[0] == '3' }
GridCharacter.includes(:object)
.where(characters: { granblue_id: excluded })
.where('grid_characters.party_id = parties.id')
end
# Generates subquery for excluded summons
# @return [ActiveRecord::Relation, nil] exclusion query
def excluded_summons
return unless params[:excludes]
excluded = params[:excludes].split(',').filter { |id| id[0] == '2' }
GridSummon.includes(:object)
.where(summons: { granblue_id: excluded })
.where('grid_summons.party_id = parties.id')
end
# Generates subquery for excluded weapons
# @return [ActiveRecord::Relation, nil] exclusion query
def excluded_weapons
return unless params[:excludes]
excluded = params[:excludes].split(',').filter { |id| id[0] == '1' }
GridWeapon.includes(:object)
.where(weapons: { granblue_id: excluded })
.where('grid_weapons.party_id = parties.id')
end
# == Query Processing
# Fetches and processes parties query with pagination
# @param query [ActiveRecord::Relation] base query
# @return [ActiveRecord::Relation] processed and paginated parties
def fetch_parties(query)
query.order(created_at: :desc)
.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
.each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false }
end
# Calculates total count for pagination
# @param query [ActiveRecord::Relation] current query
# @return [Integer] total count
def calculate_count(query)
# query.count.values.sum
query.count
end
# Calculates total pages for pagination
# @param count [Integer] total record count
# @return [Integer] total pages
def calculate_total_pages(count)
# count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
(count.to_f / COLLECTION_PER_PAGE).ceil
end
# == Include/Exclude Processing
# Generates SQL for including specific items
# @param id [String] item identifier
# @return [String] SQL condition
def includes(id)
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
end
# Generates SQL for excluding specific items
# @param id [String] item identifier
# @return [String] SQL condition
def excludes(id)
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
end
# == Filter Condition Helpers
# Generates user quality condition
# @return [String, nil] SQL condition for user quality
def user_quality
return if params[:user_quality].blank? || params[:user_quality] == 'false'
'user_id IS NOT NULL'
end
# Generates name quality condition
# @return [String, nil] SQL condition for name quality
def name_quality
return if params[:name_quality].blank? || params[:name_quality] == 'false'
low_quality = [
'Untitled',
'Remix of Untitled',
'Remix of Remix of Untitled',
'Remix of Remix of Remix of Untitled',
'Remix of Remix of Remix of Remix of Untitled',
'Remix of Remix of Remix of Remix of Remix of Untitled',
'無題',
'無題のリミックス',
'無題のリミックスのリミックス',
'無題のリミックスのリミックスのリミックス',
'無題のリミックスのリミックスのリミックスのリミックス',
'無題のリミックスのリミックスのリミックスのリミックスのリミックス'
]
joined_names = low_quality.map { |name| "'#{name}'" }.join(',')
"name NOT IN (#{joined_names})"
end
# Generates original party condition
# @return [String, nil] SQL condition for original parties
def original
return if params['original'].blank? || params['original'] == 'false'
'source_party_id IS NULL'
end
# == Filter Condition Helpers
# Generates privacy condition based on favorites
# @param favorites [Boolean] whether viewing favorites
# @return [String, nil] SQL condition
def privacy(favorites: false)
return if admin_mode
if favorites
'visibility < 3'
else
'visibility = 1'
end
end
# == Utility Methods
# Maps ID prefixes to table names
# @param id [String] item identifier
# @return [Array(String, String)] corresponding table name
def grid_table_and_object_table(id)
case id[0]
when '3'
%w[grid_characters characters]
when '2'
%w[grid_summons summons]
when '1'
%w[grid_weapons weapons]
else
[nil, nil]
end
end
# Generates name for remixed party
# @param name [String] original party name
# @return [String] generated remix name
def remixed_name(name)
blanked_name = {
en: name.blank? ? 'Untitled team' : name,
ja: name.blank? ? '無名の編成' : name
}
if current_user
case current_user.language
when 'en'
"Remix of #{blanked_name[:en]}"
when 'ja'
"#{blanked_name[:ja]}のリミックス"
end
else
"Remix of #{blanked_name[:en]}"
end
end
# == Party Loading
# Loads party by shortcode for routes using :id
# @return [void]
def set_from_slug
@party = Party.includes(
:user,
:job,
{ raid: :group },
{ characters: [:character, :awakening] },
{
weapons: {
# Eager load the associated weapon and its awakenings.
weapon: [:awakenings],
# Eager load the grid 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?
params.require(:party).permit(
:user_id,
:local_id,
:edit_key,
:extra,
:name,
:description,
:raid_id,
:job_id,
:visibility,
:accessory_id,
:skill0_id,
:skill1_id,
:skill2_id,
:skill3_id,
:full_auto,
:auto_guard,
:auto_summon,
:charge_attack,
:clear_time,
:button_count,
:turn_count,
:chain_count,
:guidebook1_id,
:guidebook2_id,
:guidebook3_id,
characters_attributes: [:id, :party_id, :character_id, :position,
:uncap_level, :transcendence_step, :perpetuity,
:awakening_id, :awakening_level,
{ ring1: %i[modifier strength], ring2: %i[modifier strength],
ring3: %i[modifier strength], ring4: %i[modifier strength],
earring: %i[modifier strength] }],
summons_attributes: %i[id party_id summon_id position main friend
quick_summon uncap_level transcendence_step],
weapons_attributes: %i[id party_id weapon_id
position mainhand uncap_level transcendence_step element
weapon_key1_id weapon_key2_id weapon_key3_id
ax_modifier1 ax_modifier2 ax_strength1 ax_strength2
awakening_id awakening_level]
)
end
end
end
end