496 lines
19 KiB
Ruby
496 lines
19 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
module Api
|
||
module V1
|
||
##
|
||
# Controller handling API requests related to grid summons within a party.
|
||
#
|
||
# This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid summons.
|
||
# It ensures that the correct party and summons are found and that the current user (or edit key) is authorized.
|
||
#
|
||
# @see Api::V1::ApiController for shared API behavior.
|
||
class GridSummonsController < Api::V1::ApiController
|
||
include IdResolvable
|
||
|
||
attr_reader :party, :incoming_summon
|
||
|
||
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon update_position resolve destroy sync]
|
||
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon update_position swap resolve destroy sync]
|
||
before_action :find_incoming_summon, only: :create
|
||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon update_position swap destroy sync]
|
||
|
||
##
|
||
# Creates a new grid summon.
|
||
#
|
||
# This method builds a new grid summon using the permitted parameters merged
|
||
# with the party and summon IDs. It ensures that the `uncap_level` is set to the
|
||
# maximum allowed level if not provided. Depending on validation, it will either save
|
||
# the summon, handle conflict resolution, or render a validation error response.
|
||
#
|
||
# @return [void]
|
||
def create
|
||
# Build a new grid summon using permitted parameters merged with party and summon IDs.
|
||
# Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
|
||
grid_summon = build_grid_summon.tap do |gs|
|
||
gs.uncap_level = max_uncap_level(gs.summon)
|
||
end
|
||
|
||
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
||
if grid_summon.valid?
|
||
save_summon(grid_summon)
|
||
# If it is invalid due to a conflict error, handle the conflict resolution flow.
|
||
elsif conflict_error?(grid_summon)
|
||
handle_conflict(grid_summon)
|
||
# If there's some other kind of validation error, render the validation error response back to the client.
|
||
else
|
||
render_validation_error_response(grid_summon)
|
||
end
|
||
end
|
||
|
||
##
|
||
# Updates an existing grid summon.
|
||
#
|
||
# Updates the grid summon attributes using permitted parameters. If the update is successful,
|
||
# it renders the updated grid summon view; otherwise, it renders a validation error response.
|
||
#
|
||
# @return [void]
|
||
def update
|
||
@grid_summon.attributes = summon_params
|
||
|
||
return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save
|
||
|
||
render_validation_error_response(@grid_summon)
|
||
end
|
||
|
||
##
|
||
# Updates the uncap level and transcendence step of a grid summon.
|
||
#
|
||
# This action recalculates the maximum allowed uncap level based on the summon attributes
|
||
# and applies business logic to adjust the uncap level and transcendence step accordingly.
|
||
# On success, it renders the updated grid summon view; otherwise, it renders a validation error response.
|
||
#
|
||
# @return [void]
|
||
def update_uncap_level
|
||
summon = @grid_summon.summon
|
||
max_level = max_uncap_level(summon)
|
||
|
||
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_level
|
||
can_be_transcended = summon.transcendence &&
|
||
summon_params[:transcendence_step].present? &&
|
||
summon_params[:transcendence_step].to_i.positive?
|
||
|
||
new_uncap_level = greater_than_max_uncap || can_be_transcended ? max_level : summon_params[:uncap_level]
|
||
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
|
||
|
||
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
|
||
render json: GridSummonBlueprint.render(@grid_summon, view: :uncap, root: :grid_summon)
|
||
else
|
||
render_validation_error_response(@grid_summon)
|
||
end
|
||
end
|
||
|
||
##
|
||
# Updates the quick summon status for a grid summon.
|
||
#
|
||
# If the grid summon is in positions 4, 5, or 6, no update is performed.
|
||
# Otherwise, it disables quick summon for all other summons in the party,
|
||
# updates the current summon, and renders the updated list of summons.
|
||
#
|
||
# @return [void]
|
||
def update_quick_summon
|
||
return if [4, 5, 6].include?(@grid_summon.position)
|
||
|
||
quick_summons = @grid_summon.party.summons.select(&:quick_summon)
|
||
|
||
quick_summons.each do |summon|
|
||
summon.update!(quick_summon: false)
|
||
end
|
||
|
||
@grid_summon.update!(quick_summon: summon_params[:quick_summon])
|
||
return unless @grid_summon.persisted?
|
||
|
||
quick_summons -= [@grid_summon]
|
||
summons = [@grid_summon] + quick_summons
|
||
|
||
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
|
||
end
|
||
|
||
##
|
||
# Updates the position of a GridSummon.
|
||
#
|
||
# Moves a grid summon to a new position, optionally changing its container.
|
||
# Validates that the target position is empty and within allowed bounds.
|
||
#
|
||
# @return [void]
|
||
def update_position
|
||
new_position = position_params[:position].to_i
|
||
new_container = position_params[:container]
|
||
|
||
# Validate position bounds (-1 main, 0-3 sub, 4-5 subaura, 6 friend)
|
||
unless valid_summon_position?(new_position)
|
||
return render_unprocessable_entity_response(
|
||
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for summon")
|
||
)
|
||
end
|
||
|
||
# Check if position is restricted (main summon, friend)
|
||
if restricted_summon_position?(new_position)
|
||
return render_unprocessable_entity_response(
|
||
Api::V1::InvalidPositionError.new("Cannot move summon to restricted position #{new_position}")
|
||
)
|
||
end
|
||
|
||
# Check if target position is occupied
|
||
if GridSummon.exists?(party_id: @party.id, position: new_position)
|
||
return render_unprocessable_entity_response(
|
||
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
|
||
)
|
||
end
|
||
|
||
@grid_summon.position = new_position
|
||
|
||
if @grid_summon.save
|
||
render json: {
|
||
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||
grid_summon: GridSummonBlueprint.render_as_hash(@grid_summon.reload, view: :nested)
|
||
}, status: :ok
|
||
else
|
||
render_validation_error_response(@grid_summon)
|
||
end
|
||
end
|
||
|
||
##
|
||
# Swaps positions between two GridSummons.
|
||
#
|
||
# Exchanges the positions of two grid summons within the same party.
|
||
# Both summons must belong to the same party and not be in restricted positions.
|
||
#
|
||
# @return [void]
|
||
def swap
|
||
source_id = swap_params[:source_id]
|
||
target_id = swap_params[:target_id]
|
||
|
||
source = GridSummon.find_by(id: source_id, party_id: @party.id)
|
||
target = GridSummon.find_by(id: target_id, party_id: @party.id)
|
||
|
||
unless source && target
|
||
return render_not_found_response('grid_summon')
|
||
end
|
||
|
||
# Check if either position is restricted
|
||
if restricted_summon_position?(source.position) || restricted_summon_position?(target.position)
|
||
return render_unprocessable_entity_response(
|
||
Api::V1::InvalidPositionError.new("Cannot swap summons in restricted positions")
|
||
)
|
||
end
|
||
|
||
# Perform the swap
|
||
ActiveRecord::Base.transaction do
|
||
temp_position = -999
|
||
source_pos = source.position
|
||
target_pos = target.position
|
||
|
||
source.update!(position: temp_position)
|
||
target.update!(position: source_pos)
|
||
source.update!(position: target_pos)
|
||
end
|
||
|
||
render json: {
|
||
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||
swapped: {
|
||
source: GridSummonBlueprint.render_as_hash(source.reload, view: :nested),
|
||
target: GridSummonBlueprint.render_as_hash(target.reload, view: :nested)
|
||
}
|
||
}, status: :ok
|
||
rescue ActiveRecord::RecordInvalid => e
|
||
render_validation_error_response(e.record)
|
||
end
|
||
|
||
#
|
||
# Destroys a grid summon.
|
||
#
|
||
# Finds the grid summon by ID. If not found, renders a not-found response.
|
||
# If the current user is not authorized to perform the deletion, renders an unauthorized response.
|
||
# On successful destruction, renders the destroyed grid summon view.
|
||
#
|
||
# @return [void]
|
||
def destroy
|
||
grid_summon = GridSummon.find_by('id = ?', params[:id])
|
||
|
||
return render_not_found_response('grid_summon') if grid_summon.nil?
|
||
|
||
if grid_summon.destroy
|
||
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok
|
||
else
|
||
render_unprocessable_entity_response(
|
||
Api::V1::GranblueError.new(grid_summon.errors.full_messages.join(', '))
|
||
)
|
||
end
|
||
end
|
||
|
||
##
|
||
# Syncs a grid summon from its linked collection summon.
|
||
#
|
||
# @return [void]
|
||
def sync
|
||
unless @grid_summon.collection_summon.present?
|
||
return render_unprocessable_entity_response(
|
||
Api::V1::GranblueError.new('No collection summon linked')
|
||
)
|
||
end
|
||
|
||
@grid_summon.sync_from_collection!
|
||
render json: GridSummonBlueprint.render(@grid_summon.reload,
|
||
root: :grid_summon,
|
||
view: :nested)
|
||
end
|
||
|
||
##
|
||
# Saves the provided grid summon.
|
||
#
|
||
# If an existing grid summon is found at the specified position for the party, it is replaced.
|
||
# On successful save, renders the grid summon view with a created status.
|
||
#
|
||
# @param summon [GridSummon] The grid summon instance to be saved.
|
||
# @return [void]
|
||
def save_summon(summon)
|
||
if (grid_summon = GridSummon.where(
|
||
party_id: party.id,
|
||
position: summon_params[:position]
|
||
).first)
|
||
GridSummon.destroy(grid_summon.id)
|
||
end
|
||
|
||
return unless summon.save
|
||
|
||
output = render_grid_summon_view(summon)
|
||
render json: output, status: :created
|
||
end
|
||
|
||
##
|
||
# Handles conflict resolution for a grid summon.
|
||
#
|
||
# If a conflict is detected and the conflicting summon matches the incoming summon,
|
||
# the method updates the conflicting summon’s position with the new position.
|
||
# On a successful update, renders the updated grid summon view.
|
||
#
|
||
# @param summon [GridSummon] The grid summon instance that encountered a conflict.
|
||
# @return [void]
|
||
def handle_conflict(summon)
|
||
conflict_summon = summon.conflicts(party)
|
||
return unless conflict_summon.summon.id == incoming_summon.id
|
||
|
||
old_position = conflict_summon.position
|
||
conflict_summon.position = summon_params[:position]
|
||
|
||
return unless conflict_summon.save
|
||
|
||
output = render_grid_summon_view(conflict_summon, old_position)
|
||
render json: output
|
||
end
|
||
|
||
private
|
||
|
||
##
|
||
# Finds the party based on the provided party_id parameter.
|
||
#
|
||
# Sets the @party instance variable and renders an unauthorized response if the current
|
||
# user is not the owner of the party.
|
||
#
|
||
# @return [void]
|
||
|
||
##
|
||
# Finds and sets the party based on parameters.
|
||
#
|
||
# Renders an unauthorized response if the current user is not the owner.
|
||
#
|
||
# @return [void]
|
||
def find_party
|
||
@party = Party.find_by(id: params.dig(:summon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_summon&.party
|
||
render_not_found_response('party') unless @party
|
||
end
|
||
|
||
##
|
||
# Finds and sets the GridSummon based on the provided parameters.
|
||
#
|
||
# Searches for a grid summon using various parameter keys and renders a not found response if it is absent.
|
||
#
|
||
# @return [void]
|
||
def find_grid_summon
|
||
grid_summon_id = params[:id] || params.dig(:summon, :id) || params.dig(:resolve, :conflicting)
|
||
@grid_summon = GridSummon.find_by(id: grid_summon_id)
|
||
render_not_found_response('grid_summon') unless @grid_summon
|
||
end
|
||
|
||
##
|
||
# Finds the incoming summon based on the provided parameters.
|
||
#
|
||
# Sets the @incoming_summon instance variable.
|
||
#
|
||
# @return [void]
|
||
def find_incoming_summon
|
||
@incoming_summon = find_by_any_id(Summon, summon_params[:summon_id])
|
||
end
|
||
|
||
##
|
||
# Builds a new GridSummon instance using permitted parameters.
|
||
#
|
||
# Merges the party id and the incoming summon id into the parameters.
|
||
#
|
||
# @return [GridSummon] A new grid summon instance.
|
||
def build_grid_summon
|
||
GridSummon.new(summon_params.merge(party_id: party.id, summon_id: incoming_summon.id))
|
||
end
|
||
|
||
##
|
||
# Checks whether the grid summon error is solely due to a conflict.
|
||
#
|
||
# Verifies if the errors on the :series attribute include the specific conflict message
|
||
# and confirms that a conflict exists for the current party.
|
||
#
|
||
# @param grid_summon [GridSummon] The grid summon instance to check.
|
||
# @return [Boolean] True if the error is due solely to a conflict, false otherwise.
|
||
def conflict_error?(grid_summon)
|
||
grid_summon.errors[:series].include?('must not conflict with existing summons') &&
|
||
grid_summon.conflicts(party).present?
|
||
end
|
||
|
||
##
|
||
# Renders the grid summon view with additional metadata.
|
||
#
|
||
# @param grid_summon [GridSummon] The grid summon instance to render.
|
||
# @param conflict_position [Integer, nil] The position of a conflicting summon, if applicable.
|
||
# @return [String] The rendered grid summon view as JSON.
|
||
def render_grid_summon_view(grid_summon, conflict_position = nil)
|
||
GridSummonBlueprint.render(grid_summon,
|
||
view: :nested,
|
||
root: :grid_summon,
|
||
meta: { replaced: conflict_position })
|
||
end
|
||
|
||
##
|
||
# Determines the maximum uncap level for a given summon.
|
||
#
|
||
# The maximum uncap level is determined based on the attributes of the summon:
|
||
# - Returns 4 if the summon has FLB but not ULB and is not transcended.
|
||
# - Returns 5 if the summon has ULB and is not transcended.
|
||
# - Returns 6 if the summon has transcendence.
|
||
# - Otherwise, returns 3.
|
||
#
|
||
# @param summon [Summon] The summon for which to determine the maximum uncap level.
|
||
# @return [Integer] The maximum uncap level.
|
||
def max_uncap_level(summon)
|
||
if summon.flb && !summon.ulb && !summon.transcendence
|
||
4
|
||
elsif summon.ulb && !summon.transcendence
|
||
5
|
||
elsif summon.transcendence
|
||
6
|
||
else
|
||
3
|
||
end
|
||
end
|
||
|
||
##
|
||
# Authorizes the current action by ensuring that the current user or provided edit key matches the party's owner.
|
||
#
|
||
# For parties associated with a user, it verifies that the current_user is the owner.
|
||
# For anonymous parties, it checks that the provided edit key matches the party's edit key.
|
||
#
|
||
# @return [void]
|
||
def authorize_party_edit!
|
||
if @party.user.present?
|
||
authorize_user_party
|
||
else
|
||
authorize_anonymous_party
|
||
end
|
||
end
|
||
|
||
##
|
||
# Authorizes an action for a party that belongs to a user.
|
||
#
|
||
# Renders an unauthorized response unless the current user is present and
|
||
# matches the party's user.
|
||
#
|
||
# @return [void]
|
||
def authorize_user_party
|
||
return if current_user.present? && @party.user == current_user
|
||
|
||
render_unauthorized_response
|
||
end
|
||
|
||
##
|
||
# Authorizes an action for an anonymous party using an edit key.
|
||
#
|
||
# Retrieves and normalizes the provided edit key and compares it with the party's edit key.
|
||
# Renders an unauthorized response unless the keys are valid.
|
||
#
|
||
# @return [void]
|
||
def authorize_anonymous_party
|
||
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||
|
||
render_unauthorized_response
|
||
end
|
||
|
||
##
|
||
# Validates that the provided edit key matches the party's edit key.
|
||
#
|
||
# @param provided_edit_key [String] the edit key provided in the request.
|
||
# @param party_edit_key [String] the edit key associated with the party.
|
||
# @return [Boolean] true if the edit keys match; false otherwise.
|
||
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||
provided_edit_key.present? &&
|
||
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||
end
|
||
|
||
##
|
||
# Validates if a summon position is valid.
|
||
#
|
||
# @param position [Integer] the position to validate.
|
||
# @return [Boolean] true if the position is valid; false otherwise.
|
||
def valid_summon_position?(position)
|
||
# Main (-1), sub slots (0-3), subaura (4-5), friend (6)
|
||
position == -1 || (0..6).cover?(position)
|
||
end
|
||
|
||
##
|
||
# Checks if a summon position is restricted (cannot be drag-drop target).
|
||
#
|
||
# @param position [Integer] the position to check.
|
||
# @return [Boolean] true if the position is restricted; false otherwise.
|
||
def restricted_summon_position?(position)
|
||
# Main summon (-1) and friend summon (6) are restricted
|
||
position == -1 || position == 6
|
||
end
|
||
|
||
##
|
||
# Defines and permits the whitelisted parameters for a grid summon.
|
||
#
|
||
# @return [ActionController::Parameters] The permitted parameters.
|
||
def summon_params
|
||
params.require(:summon).permit(:id, :party_id, :summon_id, :collection_summon_id,
|
||
:position, :main, :friend, :quick_summon,
|
||
:uncap_level, :transcendence_step)
|
||
end
|
||
|
||
##
|
||
# Specifies and permits the position update parameters.
|
||
#
|
||
# @return [ActionController::Parameters] the permitted parameters.
|
||
def position_params
|
||
params.permit(:position, :container)
|
||
end
|
||
|
||
##
|
||
# Specifies and permits the swap parameters.
|
||
#
|
||
# @return [ActionController::Parameters] the permitted parameters.
|
||
def swap_params
|
||
params.permit(:source_id, :target_id)
|
||
end
|
||
end
|
||
end
|
||
end
|