# frozen_string_literal: true module Api module V1 ## # Controller handling API requests related to grid characters within a party. # # This controller provides endpoints for creating, updating, resolving conflicts, # updating uncap levels, and deleting grid characters. It follows the structure of # GridSummonsController and GridWeaponsController by using the new authorization method # `authorize_party_edit!` and deprecating legacy methods such as `set` in favor of # `find_party`, `find_grid_character`, and `find_incoming_character`. # # @see Api::V1::ApiController for shared API behavior. class GridCharactersController < Api::V1::ApiController include IdResolvable before_action :find_grid_character, only: %i[update update_uncap_level update_position destroy resolve sync] before_action :find_party, only: %i[create resolve update update_uncap_level update_position swap destroy sync] before_action :find_incoming_character, only: :create before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level update_position swap destroy sync] ## # Creates a new grid character. # # If a conflicting grid character is found (i.e. one with the same character_id already exists # in the party), a conflict view is rendered so the user can decide on removal. Otherwise, # any grid character occupying the desired position is removed and a new one is created. # # @return [void] def create processed_params = transform_character_params(character_params) if conflict_characters.present? render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position]) else # Remove any existing grid character occupying the specified position. if (existing = GridCharacter.find_by(party_id: @party.id, position: character_params[:position])) existing.destroy end # Build the new grid character grid_character = build_new_grid_character(processed_params) if grid_character.save render json: GridCharacterBlueprint.render(grid_character, root: :grid_character, view: :nested), status: :created else render_validation_error_response(grid_character) end end end ## # Updates an existing grid character. # # Assigns new rings and awakening data to their respective virtual attributes and updates other # permitted attributes. On success, the updated grid character view is rendered. # # @return [void] def update processed_params = transform_character_params(character_params) assign_raw_attributes(@grid_character) assign_transformed_attributes(@grid_character, processed_params) if @grid_character.save render json: GridCharacterBlueprint.render(@grid_character, root: :grid_character, view: :nested) else render_validation_error_response(@grid_character) end end ## # Updates the uncap level and transcendence step of a grid character. # # The grid character's uncap level and transcendence step are updated based on the provided parameters. # This action requires that the current user is authorized to modify the party. # # @return [void] def update_uncap_level @grid_character.uncap_level = character_params[:uncap_level] @grid_character.transcendence_step = character_params[:transcendence_step] || 0 if @grid_character.save render json: GridCharacterBlueprint.render(@grid_character, root: :grid_character, view: :uncap) else render_validation_error_response(@grid_character) end end ## # Updates the position of a GridCharacter. # # Moves a grid character to a new position, maintaining sequential filling for main slots. # 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 (0-4 main, 5-6 extra) unless valid_character_position?(new_position) return render_unprocessable_entity_response( Api::V1::InvalidPositionError.new("Invalid position #{new_position} for character") ) end # Check if target position is occupied if GridCharacter.exists?(party_id: @party.id, position: new_position) return render_unprocessable_entity_response( Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied") ) end old_position = @grid_character.position @grid_character.position = new_position # Compact positions if needed (for main slots) reordered = compact_character_positions if should_compact_characters?(old_position, new_position) if @grid_character.save render json: { party: PartyBlueprint.render_as_hash(@party.reload, view: :full), grid_character: GridCharacterBlueprint.render_as_hash(@grid_character.reload, view: :nested), reordered: reordered || false }, status: :ok else render_validation_error_response(@grid_character) end end ## # Swaps positions between two GridCharacters. # # Exchanges the positions of two grid characters within the same party. # Both characters must belong to the same party. # # @return [void] def swap source_id = swap_params[:source_id] target_id = swap_params[:target_id] source = GridCharacter.find_by(id: source_id, party_id: @party.id) target = GridCharacter.find_by(id: target_id, party_id: @party.id) unless source && target return render_not_found_response('grid_character') 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: GridCharacterBlueprint.render_as_hash(source.reload, view: :nested), target: GridCharacterBlueprint.render_as_hash(target.reload, view: :nested) } }, status: :ok rescue ActiveRecord::RecordInvalid => e render_validation_error_response(e.record) end ## # Resolves conflicts for grid characters. # # This action destroys any conflicting grid characters as well as any grid character occupying # the target position, then creates a new grid character using a computed default uncap level. # The default uncap level is determined by the incoming character's attributes. # # @return [void] def resolve incoming = find_by_any_id(Character, resolve_params[:incoming]) render_not_found_response('character') and return unless incoming conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact conflicting.each(&:destroy) if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position])) existing.destroy end grid_character = GridCharacter.create!( party_id: @party.id, character_id: incoming.id, position: resolve_params[:position], uncap_level: compute_max_uncap_level(incoming) ) render json: GridCharacterBlueprint.render(grid_character, root: :grid_character, view: :nested), status: :created end ## # Destroys a grid character. # # If the current user is not the owner of the party, an unauthorized response is rendered. # On successful destruction, the destroyed grid character view is rendered. # # @return [void] def destroy grid_character = GridCharacter.find_by('id = ?', params[:id]) return render_not_found_response('grid_character') if grid_character.nil? if grid_character.destroy render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) else render_unprocessable_entity_response( Api::V1::GranblueError.new(grid_character.errors.full_messages.join(', ')) ) end end ## # Syncs a grid character from its linked collection character. # # Copies all customizations from the collection character to this grid character. # Returns 422 if no collection character is linked. # # @return [void] def sync unless @grid_character.collection_character.present? return render_unprocessable_entity_response( Api::V1::GranblueError.new('No collection character linked') ) end @grid_character.sync_from_collection! render json: GridCharacterBlueprint.render(@grid_character.reload, root: :grid_character, view: :nested) end private ## # Builds a new grid character using the transformed parameters. # # @param processed_params [Hash] the transformed parameters. # @return [GridCharacter] the newly built grid character. def build_new_grid_character(processed_params) grid_character = GridCharacter.new( character_params.except(:rings, :awakening).merge( party_id: @party.id, character_id: @incoming_character.id, uncap_level: compute_max_uncap_level(@incoming_character) ) ) assign_transformed_attributes(grid_character, processed_params) assign_raw_attributes(grid_character) grid_character end ## # Computes the maximum uncap level for a character based on its flags. # # Special characters (limited/seasonal) have a different uncap progression: # - Base: 3, FLB: 4, ULB: 5 # Regular characters: # - Base: 4, FLB: 5, ULB: 6 # # @param character [Character] the character to compute max uncap for. # @return [Integer] the maximum uncap level. def compute_max_uncap_level(character) if character.special character.ulb ? 5 : character.flb ? 4 : 3 else character.ulb ? 6 : character.flb ? 5 : 4 end end ## # Assigns raw attributes from the original parameters to the grid character. # # These attributes (like new_rings and new_awakening) are used by model callbacks. # Note: We exclude :character_id and :party_id because they are already set correctly # in build_new_grid_character using the resolved UUIDs, not the raw granblue_id from params. # # @param grid_character [GridCharacter] the grid character instance. # @return [void] def assign_raw_attributes(grid_character) grid_character.new_rings = character_params[:rings] if character_params[:rings].present? grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present? grid_character.assign_attributes(character_params.except(:rings, :awakening, :character_id, :party_id)) end ## # Assigns transformed attributes (such as uncap_level, transcendence_step, etc.) to the grid character. # # @param grid_character [GridCharacter] the grid character instance. # @param processed_params [Hash] the transformed parameters. # @return [void] def assign_transformed_attributes(grid_character, processed_params) grid_character.uncap_level = processed_params[:uncap_level] if processed_params[:uncap_level] grid_character.transcendence_step = processed_params[:transcendence_step] if processed_params[:transcendence_step] grid_character.perpetuity = processed_params[:perpetuity] if processed_params.key?(:perpetuity) grid_character.earring = processed_params[:earring] if processed_params[:earring] return unless processed_params[:awakening_id] grid_character.awakening_id = processed_params[:awakening_id] grid_character.awakening_level = processed_params[:awakening_level] end ## # Transforms the incoming character parameters to the required format. # # The frontend sends parameters in a raw format that need to be processed (e.g., converting string # values to integers, handling nested attributes for rings and awakening). This method extracts and # converts only the keys that were provided. # # @param raw_params [ActionController::Parameters] the raw permitted parameters. # @return [Hash] the transformed parameters. def transform_character_params(raw_params) # Convert to a symbolized hash for convenience. raw = raw_params.to_h.deep_symbolize_keys # Only update keys that were provided. transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity) transformed[:uncap_level] = raw[:uncap_level] if raw[:uncap_level].present? transformed[:transcendence_step] = raw[:transcendence_step] if raw[:transcendence_step].present? # Process rings if provided. transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present? # Process earring if provided. transformed[:earring] = raw[:earring] if raw[:earring].present? # Process awakening if provided. if raw[:awakening].present? transformed[:awakening_id] = raw[:awakening][:id] # Default to 1 if level is missing (to satisfy validations) transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level] : 1 end transformed end ## # Transforms the rings data to ensure exactly four rings are present. # # Pads the array with a default ring hash if necessary. # # @param rings [Array, Hash] the rings data from the frontend. # @return [Hash] a hash with keys :ring1, :ring2, :ring3, :ring4. def transform_rings(rings) default_ring = { modifier: nil, strength: nil } # Ensure rings is an array of hashes. rings_array = Array(rings).map(&:to_h) # Pad the array to exactly four rings if needed. rings_array.fill(default_ring, rings_array.size...4) { ring1: rings_array[0], ring2: rings_array[1], ring3: rings_array[2], ring4: rings_array[3] } end ## # Returns any grid characters in the party that conflict with the incoming character. # # Conflict is defined as any grid character already in the party with the same character_id as the # incoming character. This method is used to prompt the user for conflict resolution. # # @return [Array] def conflict_characters @party.characters.where(character_id: @incoming_character.id).to_a end def find_conflict_characters(incoming_character) # Check all character ids on incoming character against current characters conflict_ids = (current_characters & incoming_character.character_id) return unless conflict_ids.length.positive? # Find conflicting character ids in party characters party.characters.filter do |c| c if (conflict_ids & c.character.character_id).length.positive? end.flatten end def find_current_characters # Make a list of all character IDs @current_characters = party.characters.map do |c| Character.find(c.character.id).character_id end.flatten end ## # Finds and sets the party based on parameters. # # Checks for the party id in params[:character][:party_id], params[:party_id], or falls back to the party # associated with the current grid character. Renders a not found response if the party is missing. # # @return [void] def find_party @party = Party.find_by(id: params.dig(:character, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_character&.party render_not_found_response('party') unless @party end ## # Finds and sets the grid character based on the provided parameters. # # Searches for a grid character by its ID and renders a not found response if it is absent. # # @return [void] def find_grid_character grid_character_id = params[:id] || params.dig(:character, :id) || params.dig(:resolve, :conflicting) @grid_character = GridCharacter.includes(:awakening).find_by(id: grid_character_id) render_not_found_response('grid_character') unless @grid_character end ## # Finds and sets the incoming character based on the provided parameters. # # Searches for a character using the :character_id parameter and renders a not found response if it is absent. # # @return [void] def find_incoming_character character_id = character_params[:character_id] @incoming_character = find_by_any_id(Character, character_id) unless @incoming_character render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) 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 compares the provided edit key with the party's edit key. # # @return [void] def authorize_party_edit! if @party.user.present? authorize_user_party else authorize_anonymous_party end end ## # Authorizes an action for a party that belongs to a user. # # Renders an unauthorized response unless the current user is present and matches the party's user. # # @return [void] def authorize_user_party return if current_user.present? && @party.user == current_user render_unauthorized_response end ## # Authorizes an action for an anonymous party using an edit key. # # Compares the provided edit key with the party's edit key and renders an unauthorized response # if they do not match. # # @return [void] def authorize_anonymous_party provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8') party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8') return if valid_edit_key?(provided_edit_key, party_edit_key) render_unauthorized_response end ## # Validates that the provided edit key matches the party's edit key. # # @param provided_edit_key [String] the edit key provided in the request. # @param party_edit_key [String] the edit key associated with the party. # @return [Boolean] true if the keys match; false otherwise. def valid_edit_key?(provided_edit_key, party_edit_key) provided_edit_key.present? && provided_edit_key.bytesize == party_edit_key.bytesize && ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) end ## # Validates if a character position is valid. # # @param position [Integer] the position to validate. # @return [Boolean] true if the position is valid; false otherwise. def valid_character_position?(position) # Main slots (0-4), extra slots (5-7) for unlimited raids (0..7).cover?(position) end ## # Determines if character positions should be compacted. # # @param old_position [Integer] the old position. # @param new_position [Integer] the new position. # @return [Boolean] true if compaction is needed; false otherwise. def should_compact_characters?(old_position, new_position) # Compact if moving from main slots (0-4) to extra (5-7) or vice versa main_to_extra = (0..4).cover?(old_position) && (5..7).cover?(new_position) extra_to_main = (5..7).cover?(old_position) && (0..4).cover?(new_position) main_to_extra || extra_to_main end ## # Compacts character positions to maintain sequential filling. # # @return [Boolean] true if positions were reordered; false otherwise. def compact_character_positions main_characters = @party.characters.where(position: 0..4).order(:position) ActiveRecord::Base.transaction do main_characters.each_with_index do |char, index| char.update!(position: index) if char.position != index end end true end ## # Specifies and permits the allowed character parameters. # # @return [ActionController::Parameters] the permitted parameters. def character_params params.require(:character).permit( :id, :party_id, :character_id, :collection_character_id, :position, :uncap_level, :transcendence_step, :perpetuity, awakening: %i[id level], rings: %i[modifier strength], earring: %i[modifier strength] ) 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 ## # Specifies and permits the allowed resolve parameters. # # @return [ActionController::Parameters] the permitted parameters. def resolve_params params.require(:resolve).permit(:position, :incoming, conflicting: []) end end end end