hensei-api/app/controllers/api/v1/grid_summons_controller.rb

496 lines
19 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 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 summons position with the new position.
# On a successful update, renders the updated grid summon view.
#
# @param summon [GridSummon] The grid summon instance that encountered a conflict.
# @return [void]
def handle_conflict(summon)
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