Add first round of tests (#178)

* Install Rspec

* Create .aidigestignore

* Update rails_helper

- Added sections and comments
- Add support for loading via canonical.rb
- Add FactoryBot syntax methods
- Disable SQL logging in test environment

* Move gems around

* Add canonical.rb and test env CSVs

We load these CSVs via canonical.rb when we run tests as a data source for canonical objects.

* Remove RBS for now

This is too much and we need to find the right solution

* Refactor GridSummonsController and add tests

* Create GridSummon factory

* Refactor GridSummon and add documentation and tests

* Create have_error_on.rb

* Update .aidigestignore

* Fix warnings

* Add GridWeapons and Parties factories

* Refactor GridWeapon and add documentation and tests

* Create .rubocop.yml

* Create no_weapon_provided_error.rb

* Refactor GridWeaponsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Refactor GridSummonsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Enable shoulda/matchers

* Update User factory

* Update party.rb

We moved updating the party's element and extra flag to inside the party. We use an after_commit hook to minimize the amount of queries we're running to do this.

* Update party.rb

We change setting the edit key to use the conditional assignment operator so that it doesn't get overridden when we're running tests. This shouldn't have an effect in production.

* Update api_controller.rb

Change render_unprocessable_entity_response to render the errors hash instead of the exception so that we get more helpful errors.

* Add new errors

Added NoCharacterProvidedError and NoSummonProvidedError

* Add tests and docs to GridCharacter

We added a factory, spec and documentation to the GridCharacter model

* Ensure numericality

* Move enums into GranblueEnums

We don't use these yet, but it gives us a structured place to pull them from.

* Refactor GridCharactersController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Add debug hook and other small changes

* Update grid_characters_controller.rb

Removes logs

* Update .gitignore

* Update .aidigestignore

* Refactored PartiesController

- Split PartiesController into three concerns
- Implemented testing for PartiesController and two concerns
- Implemented fixes across other files to ensure PartiesController tests pass
- Added Favorites factory

* Implement SimpleCov

* Refactor Party model

- Refactors Party model
- Adds tests
- Adds documentation

* Update granblue_enums.rb

Remove included block
This commit is contained in:
Justin Edmund 2025-02-12 02:42:30 -08:00 committed by GitHub
parent 6cf11e6517
commit d6300f7aeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 4970 additions and 1821 deletions

26
.aidigestignore Normal file
View file

@ -0,0 +1,26 @@
.*
app/assets
bin/
coverage/
download/
export/
log/
postgres/
public/
storage/
tmp/
vendor/
lib/
sig/
test/
LICENSE
logfile
config.ru
codebase.md
Rakefile
db/migrate/*
db/data/*
db/seed/updates/*

3
.gitignore vendored
View file

@ -21,6 +21,9 @@
!/log/.keep
!/tmp/.keep
# Ignore simplecov directory
/coverage/*
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/

1
.rspec Normal file
View file

@ -0,0 +1 @@
--require spec_helper

2
.rubocop.yml Normal file
View file

@ -0,0 +1,2 @@
Layout/MultilineOperationIndentation:
EnforcedStyle: aligned

View file

@ -84,8 +84,7 @@ end
group :development, :test do
gem 'amazing_print'
gem 'dotenv-rails'
gem 'factory_bot_rails'
gem 'faker'
gem 'prosopite'
gem 'pry'
gem 'rspec_junit_formatter'
gem 'rspec-rails'
@ -94,7 +93,6 @@ end
group :development do
gem 'listen'
gem 'pg_query'
gem 'prosopite'
gem 'solargraph'
gem 'spring'
gem 'spring-commands-rspec'
@ -109,6 +107,9 @@ group :test do
gem 'api_matchers'
gem 'byebug'
gem 'database_cleaner'
gem 'factory_bot_rails'
gem 'faker'
gem 'rspec'
gem 'shoulda-matchers'
gem 'simplecov', require: false
end

View file

@ -485,6 +485,7 @@ DEPENDENCIES
rails
redis
responders
rspec
rspec-rails
rspec_junit_formatter
rubocop

View file

@ -28,7 +28,7 @@ module Api
# Metadata associations
field :favorited do |party, options|
party.is_favorited(options[:current_user])
party.favorited?(options[:current_user])
end
# For collection views
@ -41,7 +41,7 @@ module Api
view :full do
# Primary object associations
include_view :nested_objects # Characters, Weapons, Summons
include_view :nested_metadata # Remixes, Source party
include_view :remix_metadata # Remixes, Source party
include_view :job_metadata # Accessory, Skills, Guidebooks
end
@ -85,11 +85,15 @@ module Api
end
end
view :nested_metadata do
view :source_party do
association :source_party,
blueprint: PartyBlueprint,
view: :minimal,
view: :preview,
if: ->(_field_name, party, _options) { party.source_party_id.present? }
end
view :remix_metadata do
include_view :source_party
# Re-added remixes association
association :remixes,
@ -127,6 +131,11 @@ module Api
fields :edit_key
end
view :remixed do
include_view :created
include_view :source_party
end
# Destroyed view
view :destroyed do
fields :name, :description, :created_at, :updated_at

View file

@ -86,7 +86,7 @@ module Api
end
def render_unprocessable_entity_response(exception)
render json: ErrorBlueprint.render_as_json(nil, exception: exception),
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
status: :unprocessable_entity
end

View file

@ -2,73 +2,115 @@
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
attr_reader :party, :incoming_character, :current_characters
before_action :find_party, only: :create
before_action :set, only: %i[update destroy]
before_action :authorize, only: %i[create update destroy]
before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve]
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
before_action :find_incoming_character, only: :create
before_action :find_current_characters, only: :create
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
##
# 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
if !conflict_characters.nil? && conflict_characters.length.positive?
# Render a template with the conflicting and incoming characters,
# as well as the selected position, so the user can be presented with
# a decision.
processed_params = transform_character_params(character_params)
# Up to 3 characters can be removed at the same time
conflict_view = render_conflict_view(conflict_characters, incoming_character, character_params[:position])
render json: conflict_view
if conflict_characters.present?
render json: render_conflict_view(conflict_characters, @incoming_character, character_params[:position])
else
# Destroy the grid character in the position if it is already filled
if GridCharacter.where(party_id: party.id, position: character_params[:position]).exists?
character = GridCharacter.where(party_id: party.id, position: character_params[:position]).limit(1)[0]
character.destroy
# 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
# Then, create a new grid character
character = GridCharacter.create!(character_params.merge(party_id: party.id,
character_id: incoming_character.id))
# Build the new grid character
grid_character = build_new_grid_character(processed_params)
if character.save!
grid_character_view = render_grid_character_view(character)
render json: grid_character_view, status: :created
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
permitted = character_params.to_h.deep_symbolize_keys
puts "Permitted:"
ap permitted
processed_params = transform_character_params(character_params)
assign_raw_attributes(@grid_character)
assign_transformed_attributes(@grid_character, processed_params)
# 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)
if @grid_character.save
render json: GridCharacterBlueprint.render(@grid_character,
root: :grid_character,
view: :nested)
else
render_validation_error_response(@character)
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]
if @grid_character.save
render json: GridCharacterBlueprint.render(@grid_character,
root: :grid_character,
view: :nested)
else
render_validation_error_response(@grid_character)
end
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 = Character.find(resolve_params[:incoming])
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find(id) }
party = conflicting.first.party
incoming = Character.find_by(id: resolve_params[:incoming])
render_not_found_response('character') and return unless incoming
# Destroy each conflicting character
conflicting.each { |character| GridCharacter.destroy(character.id) }
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
conflicting.each(&:destroy)
# Destroy the character at the desired position if it exists
existing_character = GridCharacter.where(party: party.id, position: resolve_params[:position]).first
GridCharacter.destroy(existing_character.id) if existing_character
if (existing = GridCharacter.find_by(party_id: @party.id, position: resolve_params[:position]))
existing.destroy
end
# Compute the default uncap level based on the incoming character's flags.
if incoming.special
uncap_level = 3
uncap_level = 5 if incoming.ulb
@ -79,33 +121,146 @@ module Api
uncap_level = 5 if incoming.flb
end
character = GridCharacter.create!(party_id: party.id, character_id: incoming.id,
position: resolve_params[:position], uncap_level: uncap_level)
render json: GridCharacterBlueprint.render(character, view: :nested), status: :created if character.save!
grid_character = GridCharacter.create!(
party_id: @party.id,
character_id: incoming.id,
position: resolve_params[:position],
uncap_level: uncap_level
)
render json: GridCharacterBlueprint.render(grid_character,
root: :grid_character,
view: :nested), status: :created
end
def update_uncap_level
character = GridCharacter.find(character_params[:id])
render_unauthorized_response if current_user && (character.party.user != current_user)
character.uncap_level = character_params[:uncap_level]
character.transcendence_step = character_params[:transcendence_step]
return unless character.save!
render json: GridCharacterBlueprint.render(character, view: :nested, root: :grid_character)
end
# TODO: Implement removing characters
##
# 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
render_unauthorized_response if @character.party.user != current_user
return render json: GridCharacterBlueprint.render(@character, view: :destroyed) if @character.destroy
grid_character = GridCharacter.find_by('id = ?', params[:id])
return render_not_found_response('grid_character') if grid_character.nil?
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy
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
)
)
assign_transformed_attributes(grid_character, processed_params)
assign_raw_attributes(grid_character)
grid_character
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.
#
# @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))
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<GridCharacter>]
def conflict_characters
@conflict_characters ||= find_conflict_characters(incoming_character)
@party.characters.where(character_id: @incoming_character.id).to_a
end
def find_conflict_characters(incoming_character)
@ -127,67 +282,102 @@ module Api
end.flatten
end
def set
@character = GridCharacter.includes(:awakening).find(params[:id])
end
def find_incoming_character
@incoming_character = Character.find(character_params[:character_id])
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(character_params[:party_id])
render_unauthorized_response if current_user && (party.user != current_user)
@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
def authorize
# Create
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
unauthorized_update = @character && @character.party && (@character.party.user != current_user || @character.party.edit_key != edit_key)
render_unauthorized_response if unauthorized_create || unauthorized_update
##
# 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
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
##
# 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
@incoming_character = Character.find_by(id: character_params[:character_id])
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character
end
transformed
##
# 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
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]
}
##
# 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
# Specify whitelisted properties that can be modified.
##
# 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
##
# Specifies and permits the allowed character parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def character_params
params.require(:character).permit(
:id,
@ -203,21 +393,13 @@ module Api
)
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
def render_conflict_view(conflict_characters, incoming_character, incoming_position)
ConflictBlueprint.render(nil,
view: :characters,
conflict_characters: conflict_characters,
incoming_character: incoming_character,
incoming_position: incoming_position)
end
def render_grid_character_view(grid_character)
GridCharacterBlueprint.render(grid_character, view: :nested)
end
end
end
end

View file

@ -2,82 +2,142 @@
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
attr_reader :party, :incoming_summon
before_action :set, only: %w[update update_uncap_level update_quick_summon]
before_action :find_party, only: :create
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy]
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy]
before_action :find_incoming_summon, only: :create
before_action :authorize, only: %i[create update update_uncap_level update_quick_summon destroy]
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
##
# 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
# Create the GridSummon with the desired parameters
summon = GridSummon.new
summon.attributes = summon_params.merge(party_id: party.id, summon_id: incoming_summon.id)
summon.uncap_level = max_uncap_level(summon) if summon.uncap_level.nil?
# Build a new grid summon using permitted parameters merged with party and summon IDs.
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
# if it hasn't already been provided.
grid_summon = build_grid_summon.tap do |gs|
gs.uncap_level ||= max_uncap_level(gs)
end
if summon.validate
save_summon(summon)
# 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
handle_conflict(summon)
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
@summon.attributes = summon_params
@grid_summon.attributes = summon_params
return render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon) if @summon.save
return render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) if @grid_summon.save
render_validation_error_response(@character)
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 = @summon.summon
max_uncap_level = max_uncap_level(summon)
summon = @grid_summon.summon
max_level = max_uncap_level(summon)
greater_than_max_uncap = summon_params[:uncap_level].to_i > max_uncap_level
can_be_transcended = summon.transcendence && summon_params[:transcendence_step] && summon_params[:transcendence_step]&.to_i&.positive?
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?
uncap_level = if greater_than_max_uncap || can_be_transcended
max_uncap_level
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: :nested, root: :grid_summon)
else
summon_params[:uncap_level]
end
transcendence_step = if summon.transcendence && summon_params[:transcendence_step]
summon_params[:transcendence_step]
else
0
end
@summon.update!(
uncap_level: uncap_level,
transcendence_step: transcendence_step
)
return unless @summon.persisted?
render json: GridSummonBlueprint.render(@summon, view: :nested, root: :grid_summon)
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?(@summon.position)
return if [4, 5, 6].include?(@grid_summon.position)
quick_summons = @summon.party.summons.select(&:quick_summon)
quick_summons = @grid_summon.party.summons.select(&:quick_summon)
quick_summons.each do |summon|
summon.update!(quick_summon: false)
end
@summon.update!(quick_summon: summon_params[:quick_summon])
return unless @summon.persisted?
@grid_summon.update!(quick_summon: summon_params[:quick_summon])
return unless @grid_summon.persisted?
quick_summons -= [@summon]
summons = [@summon] + quick_summons
quick_summons -= [@grid_summon]
summons = [@grid_summon] + quick_summons
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
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?
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy
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,
@ -92,6 +152,15 @@ module Api
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
@ -105,14 +174,96 @@ module Api
render json: output
end
def destroy
summon = GridSummon.find_by('id = ?', params[:id])
render_unauthorized_response if summon.party.user != current_user
return render json: GridSummonBlueprint.render(summon, view: :destroyed) if summon.destroy
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 = Summon.find_by(id: 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
@ -125,35 +276,65 @@ module Api
end
end
def find_incoming_summon
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
##
# 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
def find_party
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
@party = Party.find(summon_params[:party_id])
render_unauthorized_response if current_user && (party.user != current_user)
##
# 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
def render_grid_summon_view(grid_summon, conflict_position = nil)
GridSummonBlueprint.render(grid_summon, view: :nested,
root: :grid_summon,
meta: { replaced: conflict_position })
##
# 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
def authorize
# Create
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
unauthorized_update = @summon && @summon.party && (@summon.party.user != current_user || @summon.party.edit_key != edit_key)
render_unauthorized_response if unauthorized_create || unauthorized_update
##
# 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
def set
@summon = GridSummon.find_by('id = ?', summon_params[:id])
end
# Specify whitelisted properties that can be modified.
##
# 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, :position, :main, :friend,
:quick_summon, :uncap_level, :transcendence_step)

View file

@ -2,110 +2,143 @@
module Api
module V1
##
# Controller handling API requests related to grid weapons within a party.
#
# This controller provides endpoints for creating, updating, resolving conflicts, and deleting grid weapons.
# It ensures that the correct party and weapon are found and that the current user (or edit key) is authorized.
#
# @see Api::V1::ApiController for shared API behavior.
class GridWeaponsController < Api::V1::ApiController
attr_reader :party, :incoming_weapon
before_action :set, except: %w[create update_uncap_level]
before_action :find_party, only: :create
before_action :find_incoming_weapon, only: :create
before_action :authorize, only: %i[create update destroy]
before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy]
before_action :find_party, only: %i[create update update_uncap_level resolve destroy]
before_action :find_incoming_weapon, only: %i[create resolve]
before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
##
# Creates a new GridWeapon.
#
# Builds a new GridWeapon using parameters merged with the party and weapon IDs.
# If the model validations (including compatibility and conflict validations)
# pass, the weapon is saved; otherwise, conflict resolution is attempted.
#
# @return [void]
def create
# Create the GridWeapon with the desired parameters
weapon = GridWeapon.new
weapon.attributes = weapon_params.merge(party_id: party.id, weapon_id: incoming_weapon.id)
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
if weapon.validate
save_weapon(weapon)
else
handle_conflict(weapon)
end
end
def resolve
incoming = Weapon.find(resolve_params[:incoming])
conflicting = resolve_params[:conflicting].map { |id| GridWeapon.find(id) }
party = conflicting.first.party
# Destroy each conflicting weapon
conflicting.each { |weapon| GridWeapon.destroy(weapon.id) }
# Destroy the weapon at the desired position if it exists
existing_weapon = GridWeapon.where(party: party.id, position: resolve_params[:position]).first
GridWeapon.destroy(existing_weapon.id) if existing_weapon
uncap_level = 3
uncap_level = 4 if incoming.flb
uncap_level = 5 if incoming.ulb
weapon = GridWeapon.create!(party_id: party.id, weapon_id: incoming.id,
position: resolve_params[:position], uncap_level: uncap_level)
return unless weapon.save
view = render_grid_weapon_view(weapon, resolve_params[:position])
render json: view, status: :created
end
def update
render_unauthorized_response if current_user && (@weapon.party.user != current_user)
# TODO: Server-side validation of weapon mods
# We don't want someone modifying the JSON and adding
# keys to weapons that cannot have them
# Maybe we make methods on the model to validate for us somehow
@weapon.assign_attributes(weapon_params)
@weapon.ax_modifier1 = nil if weapon_params[:ax_modifier1] == -1
@weapon.ax_modifier2 = nil if weapon_params[:ax_modifier2] == -1
@weapon.ax_strength1 = nil if weapon_params[:ax_strength1]&.zero?
@weapon.ax_strength2 = nil if weapon_params[:ax_strength2]&.zero?
render json: GridWeaponBlueprint.render(@weapon, view: :nested) if @weapon.save
end
def destroy
render_unauthorized_response if @weapon.party.user != current_user
return render json: GridCharacterBlueprint.render(@weapon, view: :destroyed) if @weapon.destroy
end
def update_uncap_level
weapon = GridWeapon.find(weapon_params[:id])
object = weapon.weapon
max_uncap_level = max_uncap_level(object)
render_unauthorized_response if current_user && (weapon.party.user != current_user)
greater_than_max_uncap = weapon_params[:uncap_level].to_i > max_uncap_level
can_be_transcended = object.transcendence && weapon_params[:transcendence_step] && weapon_params[:transcendence_step]&.to_i&.positive?
uncap_level = if greater_than_max_uncap || can_be_transcended
max_uncap_level
else
weapon_params[:uncap_level]
end
transcendence_step = if object.transcendence && weapon_params[:transcendence_step]
weapon_params[:transcendence_step]
else
0
end
weapon.update!(
uncap_level: uncap_level,
transcendence_step: transcendence_step
grid_weapon = GridWeapon.new(
weapon_params.merge(
party_id: @party.id,
weapon_id: @incoming_weapon.id
)
)
return unless weapon.persisted?
if grid_weapon.valid?
save_weapon(grid_weapon)
else
if grid_weapon.errors[:series].include?('must not conflict with existing weapons')
handle_conflict(grid_weapon)
else
render_validation_error_response(grid_weapon)
end
end
end
render json: GridWeaponBlueprint.render(weapon, view: :nested, root: :grid_weapon)
##
# Updates an existing GridWeapon.
#
# After checking authorization, assigns new attributes to the weapon.
# Also normalizes modifier and strength fields, then renders the updated view on success.
#
# @return [void]
def update
normalize_ax_fields!
if @grid_weapon.update(weapon_params)
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
else
render_validation_error_response(@grid_weapon)
end
end
##
# Updates the uncap level and transcendence step of a GridWeapon.
#
# Finds the weapon to update, computes the maximum allowed uncap level based on its associated
# weapons flags, and then updates the fields accordingly.
#
# @return [void]
def update_uncap_level
max_uncap = compute_max_uncap_level(@grid_weapon.weapon)
requested_uncap = weapon_params[:uncap_level].to_i
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i)
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
else
render_validation_error_response(@grid_weapon)
end
end
##
# Resolves conflicts by removing conflicting grid weapons and creating a new one.
#
# Expects resolve parameters that include the desired position, the incoming weapon ID,
# and a list of conflicting GridWeapon IDs. After deleting conflicting records and any existing
# grid weapon at that position, creates a new GridWeapon with computed uncap_level.
#
# @return [void]
def resolve
incoming = Weapon.find_by(id: resolve_params[:incoming])
conflicting_ids = resolve_params[:conflicting]
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
# Destroy each conflicting weapon
conflicting_weapons.each(&:destroy)
# Destroy the weapon at the desired position if it exists
if (existing_weapon = GridWeapon.find_by(party_id: @party.id, position: resolve_params[:position]))
existing_weapon.destroy
end
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
new_uncap = compute_default_uncap(incoming)
grid_weapon = GridWeapon.create!(
party_id: @party.id,
weapon_id: incoming.id,
position: resolve_params[:position],
uncap_level: new_uncap,
transcendence_step: 0
)
if grid_weapon.persisted?
render json: GridWeaponBlueprint.render(grid_weapon, view: :full, root: :grid_weapon, meta: { replaced: resolve_params[:position] }), status: :created
else
render_validation_error_response(grid_weapon)
end
end
##
# Destroys a GridWeapon.
#
# Checks authorization and, if allowed, destroys the weapon and renders the destroyed view.
#
# @return [void]
def destroy
grid_weapon = GridWeapon.find_by('id = ?', params[:id])
return render_not_found_response('grid_weapon') if grid_weapon.nil?
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
end
private
def max_uncap_level(weapon)
##
# Computes the maximum uncap level for a given weapon based on its flags.
#
# @param weapon [Weapon] the associated weapon.
# @return [Integer] the maximum allowed uncap level.
def compute_max_uncap_level(weapon)
if weapon.flb && !weapon.ulb && !weapon.transcendence
4
elsif weapon.ulb && !weapon.transcendence
@ -117,122 +150,213 @@ module Api
end
end
def check_weapon_compatibility
return if compatible_with_position?(incoming_weapon, weapon_params[:position])
raise Api::V1::IncompatibleWeaponForPositionError.new(weapon: incoming_weapon)
##
# Computes the default uncap level for an incoming weapon.
#
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
#
# @param incoming [Weapon] the incoming weapon.
# @return [Integer] the default uncap level.
def compute_default_uncap(incoming)
compute_max_uncap_level(incoming)
end
# Check if the incoming weapon is compatible with the specified position
def compatible_with_position?(incoming_weapon, position)
false if [9, 10, 11].include?(position.to_i) && ![11, 16, 17, 28, 29, 34].include?(incoming_weapon.series)
true
##
# Normalizes the AX modifier fields for the weapon parameters.
#
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
#
# @return [void]
def normalize_ax_fields!
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
end
def conflict_weapon
@conflict_weapon ||= find_conflict_weapon(party, incoming_weapon)
##
# Renders the grid weapon view.
#
# @param grid_weapon [GridWeapon] the grid weapon to render.
# @param conflict_position [Integer] the position that was replaced.
# @return [String] the rendered view.
def render_grid_weapon_view(grid_weapon, conflict_position)
GridWeaponBlueprint.render(grid_weapon,
view: :full,
root: :grid_weapon,
meta: { replaced: conflict_position })
end
# Find a conflict weapon if one exists
def find_conflict_weapon(party, incoming_weapon)
return unless incoming_weapon.limit
##
# Saves the GridWeapon.
#
# Deletes any existing grid weapon at the same position,
# adjusts party attributes based on the weapon's position,
# and renders the full view upon successful save.
#
# @param weapon [GridWeapon] the grid weapon to save.
# @return [void]
def save_weapon(weapon)
# Check weapon validation and delete existing grid weapon if one already exists at position
if (existing = GridWeapon.find_by(party_id: @party.id, position: weapon.position))
existing.destroy
end
party.weapons.find do |weapon|
series_match = incoming_weapon.series == weapon.weapon.series
weapon if series_match || opus_or_draconic?(weapon.weapon) && opus_or_draconic?(incoming_weapon)
# Set the party's element if the grid weapon is being set as mainhand
if weapon.position.to_i == -1
@party.element = weapon.weapon.element
@party.save!
elsif GridWeapon::EXTRA_POSITIONS.include?(weapon.position.to_i)
@party.extra = true
@party.save!
end
if weapon.save
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
render json: output, status: :created
else
render_validation_error_response(weapon)
end
end
def find_incoming_weapon
@incoming_weapon = Weapon.find_by(id: weapon_params[:weapon_id])
##
# Handles conflicts when a new GridWeapon fails validation.
#
# Retrieves the array of conflicting grid weapons (via the models conflicts method)
# and either renders a conflict view (if the canonical weapons differ) or updates the
# conflicting grid weapon's position.
#
# @param weapon [GridWeapon] the weapon that failed validation.
# @return [void]
def handle_conflict(weapon)
conflict_weapons = weapon.conflicts(party)
# Find if one of the conflicting grid weapons is associated with the incoming weapon.
conflict_weapon = conflict_weapons.find { |gw| gw.weapon.id == incoming_weapon.id }
if conflict_weapon.nil?
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
render json: output
else
old_position = conflict_weapon.position
conflict_weapon.position = weapon_params[:position]
if conflict_weapon.save
output = render_grid_weapon_view(conflict_weapon, old_position)
render json: output
else
render_validation_error_response(conflict_weapon)
end
end
end
def find_party
# BUG: I can create grid weapons even when I'm not logged in on an authenticated party
@party = Party.find(weapon_params[:party_id])
render_unauthorized_response if current_user && (party.user != current_user)
end
def opus_or_draconic?(weapon)
[2, 3].include?(weapon.series)
end
# Render the conflict view as a string
##
# Renders the conflict view.
#
# @param conflict_weapons [Array<GridWeapon>] an array of conflicting grid weapons.
# @param incoming_weapon [Weapon] the incoming weapon.
# @param incoming_position [Integer] the desired position.
# @return [String] the rendered conflict view.
def render_conflict_view(conflict_weapons, incoming_weapon, incoming_position)
ConflictBlueprint.render(nil, view: :weapons,
ConflictBlueprint.render(nil,
view: :weapons,
conflict_weapons: conflict_weapons,
incoming_weapon: incoming_weapon,
incoming_position: incoming_position)
end
def render_grid_weapon_view(grid_weapon, conflict_position)
GridWeaponBlueprint.render(grid_weapon, view: :full,
root: :grid_weapon,
meta: { replaced: conflict_position })
##
# Finds and sets the GridWeapon based on the provided parameters.
#
# Searches for a grid weapon using various parameter keys and renders a not found response if it is absent.
#
# @return [void]
def find_grid_weapon
grid_weapon_id = params[:id] || params.dig(:weapon, :id) || params.dig(:resolve, :conflicting)
@grid_weapon = GridWeapon.find_by(id: grid_weapon_id)
render_not_found_response('grid_weapon') unless @grid_weapon
end
def save_weapon(weapon)
# Check weapon validation and delete existing grid weapon
# if one already exists at position
if (grid_weapon = GridWeapon.where(
party_id: party.id,
position: weapon_params[:position]
).first)
GridWeapon.destroy(grid_weapon.id)
end
# Set the party's element if the grid weapon is being set as mainhand
if weapon.position == -1
party.element = weapon.weapon.element
party.save!
elsif [9, 10, 11].include?(weapon.position)
party.extra = true
party.save!
end
# Render the weapon if it can be saved
return unless weapon.save
output = GridWeaponBlueprint.render(weapon, view: :full, root: :grid_weapon)
render json: output, status: :created
end
def handle_conflict(weapon)
conflict_weapons = weapon.conflicts(party)
# Map conflict weapon IDs into an array
conflict_weapon_ids = conflict_weapons.map(&:id)
if !conflict_weapon_ids.include?(incoming_weapon.id)
# Render conflict view if the underlying canonical weapons
# are not identical
output = render_conflict_view(conflict_weapons, incoming_weapon, weapon_params[:position])
render json: output
##
# Finds and sets the incoming weapon.
#
# @return [void]
def find_incoming_weapon
if params.dig(:weapon, :weapon_id).present?
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
render_not_found_response('weapon') unless @incoming_weapon
else
# Move the original grid weapon to the new position
# to preserve keys and other modifications
old_position = conflict_weapon.position
conflict_weapon.position = weapon_params[:position]
if conflict_weapon.save
output = render_grid_weapon_view(conflict_weapon, old_position)
render json: output
end
@incoming_weapon = nil
end
end
def set
@weapon = GridWeapon.where('id = ?', params[:id]).first
##
# 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(:weapon, :party_id)) || Party.find_by(id: params[:party_id]) || @grid_weapon&.party
render_not_found_response('party') unless @party
end
def authorize
# Create
unauthorized_create = @party && (@party.user != current_user || @party.edit_key != edit_key)
unauthorized_update = @weapon && @weapon.party && (@weapon.party.user != current_user || @weapon.party.edit_key != edit_key)
render_unauthorized_response if unauthorized_create || unauthorized_update
##
# 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
# Specify whitelisted properties that can be modified.
##
# 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
return 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)
return 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
##
# Specifies and permits the allowed weapon parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def weapon_params
params.require(:weapon).permit(
:id, :party_id, :weapon_id,
@ -243,6 +367,10 @@ module Api
)
end
##
# Specifies and permits the resolve parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def resolve_params
params.require(:resolve).permit(:position, :incoming, conflicting: [])
end

View file

@ -5,12 +5,11 @@ module Api
# 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]
include PartyAuthorizationConcern
include PartyQueryingConcern
include PartyPreviewConcern
# == Constants
# Constants used for filtering validations.
# Maximum number of characters allowed in a party
MAX_CHARACTERS = 5
@ -33,728 +32,163 @@ module Api
# Default maximum clear time in seconds
DEFAULT_MAX_CLEAR_TIME = 5400
# == Primary CRUD Actions
before_action :set_from_slug, except: %w[create destroy update index favorites]
before_action :set, only: %w[update destroy]
before_action :authorize_party!, only: %w[update destroy]
# Primary CRUD Actions
# Creates a new party with optional user association
# @return [void]
# Creates a new party.
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
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]
# Shows a specific party.
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
return render_unauthorized_response if @party.private? && (!current_user || not_owner?)
if @party
render json: PartyBlueprint.render(@party, view: :full, root: :party)
else
render_not_found_response('project')
end
end
# Updates an existing party's attributes
# @return [void]
# Updates an existing party.
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])
if (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
end
if @party.save
render json: PartyBlueprint.render(@party, view: :full, root: :party)
else
render_validation_error_response(@party)
end
end
# Deletes a party if the user has permission
# @return [void]
# Deletes a party.
def destroy
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
end
# == Extended Party Actions
# Extended Party Actions
# Creates a copy of an existing party with attribution
# @return [void]
# Creates a remixed copy of an existing party.
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?
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
new_party.local_id = party_params[:local_id] if party_params
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
render json: PartyBlueprint.render(new_party, view: :remixed, root: :party), status: :created
else
render_validation_error_response(new_party)
end
end
# Lists parties based on various filter criteria
# @return [void]
# Lists parties based on query parameters.
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]
# Lists parties favorited by the current user.
def favorites
raise Api::V1::UnauthorizedError unless current_user
ap "Total favorites in DB: #{Favorite.count}"
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
# Preview Management
# Serves the party's preview image
# @return [void]
# Serves the party's preview image.
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
party_preview(@party)
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]
# Returns the current preview status of a party.
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?
}
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]
# Forces regeneration of the party preview.
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
return render_unauthorized_response unless current_user && party.user_id == current_user.id
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
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]
# Loads the party by its shortcode.
def set_from_slug
@party = Party.includes(
:user,
:job,
{ raid: :group },
{ characters: [:character, :awakening] },
{
weapons: {
# Eager load the associated weapon and its awakenings.
:user, :job, { raid: :group },
{ characters: %i[character awakening] },
{ weapons: {
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
: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]
# Loads the party by its id.
def set
@party = Party.where('id = ?', params[:id]).first
end
# == Parameter Sanitization
# Sanitizes and permits party parameters
# @return [Hash, nil] permitted parameters
# Sanitizes and permits party 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],
: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]
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

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
module PartyAuthorizationConcern
extend ActiveSupport::Concern
# Checks whether the current user (or provided edit key) is authorized to modify @party.
def authorize_party!
if @party.user.present?
render_unauthorized_response unless current_user.present? && @party.user == current_user
else
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')
render_unauthorized_response unless valid_edit_key?(provided_edit_key, party_edit_key)
end
end
# Returns true if the party does not belong to the current user.
def not_owner?
if @party.user
return true if current_user && @party.user != current_user
return true if current_user.nil? && edit_key.present?
else
return true if current_user.present?
return true if current_user.nil? && (@party.edit_key != edit_key)
end
false
end
# Verifies that the provided edit key matches the party's edit key.
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
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
module PartyPreviewConcern
extend ActiveSupport::Concern
# Schedules preview generation for this party.
def schedule_preview_generation
GeneratePartyPreviewJob.perform_later(id)
end
# Handles serving the party preview image.
def party_preview(party)
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
begin
if Rails.env.production?
s3_object = coordinator.get_s3_object
send_data s3_object.body.read, filename: "#{party.shortcode}.png", type: 'image/png', disposition: 'inline'
else
send_file coordinator.local_preview_path, type: 'image/png', disposition: 'inline'
end
rescue Aws::S3::Errors::NoSuchKey
coordinator.schedule_generation unless coordinator.generation_in_progress?
send_file Rails.root.join('public', 'default-previews', "#{party.element || 'default'}.png"), type: 'image/png', disposition: 'inline'
end
end
end

View file

@ -0,0 +1,164 @@
# frozen_string_literal: true
module PartyQueryingConcern
extend ActiveSupport::Concern
include PartyConstants
# Builds the base query for parties with all required associations.
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 }
)
query = if favorites
query.joins(:favorites)
.where(favorites: { user_id: current_user.id })
.distinct.order(created_at: :desc)
else
query.order(visibility: :asc, created_at: :desc)
end
query = apply_filters(query)
query = apply_privacy_settings(query, favorites: false)
query = apply_includes(query, params[:includes]) if params[:includes].present?
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
query
end
# Renders paginated parties using PartyBlueprint.
def render_paginated_parties(parties)
render json: Api::V1::PartyBlueprint.render(
parties,
view: :preview,
root: :results,
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: PartyConstants::COLLECTION_PER_PAGE
},
current_user: current_user
)
end
# Applies filters to the query.
def apply_filters(query)
conditions = build_filters
query = query.where(conditions)
query = query.where(name_quality) if params[:name_quality].present?
query.where(
weapons_count: build_count(params[:weapons_count], PartyConstants::DEFAULT_MIN_WEAPONS)..PartyConstants::MAX_WEAPONS,
characters_count: build_count(params[:characters_count], PartyConstants::DEFAULT_MIN_CHARACTERS)..PartyConstants::MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], PartyConstants::DEFAULT_MIN_SUMMONS)..PartyConstants::MAX_SUMMONS
)
end
# Applies privacy settings based on whether the current user is an admin.
def apply_privacy_settings(query, favorites: false)
return query if admin_mode
if favorites.present?
query.where('visibility < 3')
else
query.where(visibility: 1)
end
end
# Builds filtering conditions from request parameters.
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], PartyConstants::DEFAULT_MIN_CHARACTERS)..PartyConstants::MAX_CHARACTERS,
summons_count: build_count(params[:summons_count], PartyConstants::DEFAULT_MIN_SUMMONS)..PartyConstants::MAX_SUMMONS,
weapons_count: build_count(params[:weapons_count], PartyConstants::DEFAULT_MIN_WEAPONS)..PartyConstants::MAX_WEAPONS
}.compact
end
# Returns a date range based on the recency parameter.
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
# Returns the count value or a default if blank.
def build_count(value, default)
value.blank? ? default : value.to_i
end
# Processes an option parameter.
def build_option(value)
value.to_i unless value.blank? || value.to_i == -1
end
# Applies “includes” filtering for objects in the party.
def apply_includes(query, includes)
includes.split(',').each do |id|
grid_table, object_table = grid_table_and_object_table(id)
next unless grid_table && 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 “excludes” filtering for objects in the party.
def apply_excludes(query, excludes)
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
# Maps an ids prefix to the corresponding grid and object table names.
def grid_table_and_object_table(id)
case id[0]
when '3' then %w[grid_characters characters]
when '2' then %w[grid_summons summons]
when '1' then %w[grid_weapons weapons]
else [nil, nil]
end
end
# Returns a remixed party name based on the current party name and current_user language.
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' then "Remix of #{blanked_name[:en]}"
when 'ja' then "#{blanked_name[:ja]}のリミックス"
else "Remix of #{blanked_name[:en]}"
end
else
"Remix of #{blanked_name[:en]}"
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Api
module V1
class NoCharacterProvidedError < GranblueError
def http_status
422
end
def code
'no_character_provided'
end
def message
'A valid character must be provided'
end
def to_hash
{
message: message,
code: code
}
end
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Api
module V1
class NoSummonProvidedError < GranblueError
def http_status
422
end
def code
'no_summon_provided'
end
def message
'A valid summon must be provided'
end
def to_hash
{
message: message,
code: code
}
end
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module Api
module V1
class NoWeaponProvidedError < GranblueError
def http_status
422
end
def code
'no_weapon_provided'
end
def message
'A valid weapon must be provided'
end
def to_hash
{
message: message,
code: code
}
end
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
#
# This module contains shared constants used for querying and filtering Party resources.
# It is included by controllers and concerns that require these configuration values.
#
module PartyConstants
COLLECTION_PER_PAGE = 15
DEFAULT_MIN_CHARACTERS = 3
DEFAULT_MIN_SUMMONS = 2
DEFAULT_MIN_WEAPONS = 5
MAX_CHARACTERS = 5
MAX_SUMMONS = 8
MAX_WEAPONS = 13
DEFAULT_MAX_CLEAR_TIME = 5400
end

View file

@ -48,73 +48,4 @@ class Character < ApplicationRecord
def display_resource(character)
character.name_en
end
# enum rarities: {
# R: 1,
# SR: 2,
# SSR: 3
# }
# enum elements: {
# Null: 0,
# Wind: 1,
# Fire: 2,
# Water: 3,
# Earth: 4,
# Dark: 5,
# Light: 6
# }
# enum proficiency1s: {
# Sabre: 1,
# Dagger: 2,
# Axe: 3,
# Spear: 4,
# Bow: 5,
# Staff: 6,
# Melee: 7,
# Harp: 8,
# Gun: 9,
# Katana: 10
# }, _prefix: "proficiency1"
# enum proficiency2s: {
# None: 0,
# Sabre: 1,
# Dagger: 2,
# Axe: 3,
# Spear: 4,
# Bow: 5,
# Staff: 6,
# Melee: 7,
# Harp: 8,
# Gun: 9,
# Katana: 10,
# }, _default: :None, _prefix: "proficiency2"
# enum race1s: {
# Unknown: 0,
# Human: 1,
# Erune: 2,
# Draph: 3,
# Harvin: 4,
# Primal: 5
# }, _prefix: "race1"
# enum race2s: {
# Unknown: 0,
# Human: 1,
# Erune: 2,
# Draph: 3,
# Harvin: 4,
# Primal: 5,
# None: 6
# }, _default: :None, _prefix: "race2"
# enum gender: {
# Unknown: 0,
# Male: 1,
# Female: 2,
# "Male/Female": 3
# }
end

View file

@ -0,0 +1,34 @@
module GranblueEnums
extend ActiveSupport::Concern
# Define constants for shared enum mappings.
RARITIES = { R: 1, SR: 2, SSR: 3 }.freeze
ELEMENTS = { Null: 0, Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 }.freeze
GENDERS = { Unknown: 0, Male: 1, Female: 2, "Male/Female": 3 }.freeze
# Single proficiency enum mapping used for both proficiency1 and proficiency2.
PROFICIENCY = {
None: 0,
Sabre: 1,
Dagger: 2,
Axe: 3,
Spear: 4,
Bow: 5,
Staff: 6,
Melee: 7,
Harp: 8,
Gun: 9,
Katana: 10
}.freeze
# Single race enum mapping used for both race1 and race2.
RACES = {
Unknown: 0,
Human: 1,
Erune: 2,
Draph: 3,
Harvin: 4,
Primal: 5,
None: 6
}.freeze
end

View file

@ -1,24 +1,43 @@
# frozen_string_literal: true
##
# This file defines the GridCharacter model which represents a character's grid configuration within a party.
# The GridCharacter model handles validations related to awakenings, rings, mastery values, and transcendence.
# It includes virtual attributes for processing new rings and awakening data, and utilizes the amoeba gem
# for duplicating records with specific attribute resets.
#
# @note This model belongs to a Character, an optional Awakening, and a Party. It maintains associations for
# these relationships and includes counter caches for performance optimization.
#
# @!attribute [r] character
# @return [Character] the associated character record.
# @!attribute [r] awakening
# @return [Awakening, nil] the associated awakening record (optional).
# @!attribute [r] party
# @return [Party] the associated party record.
#
class GridCharacter < ApplicationRecord
# Associations
belongs_to :character, foreign_key: :character_id, primary_key: :id
belongs_to :awakening, optional: true
belongs_to :party,
counter_cache: :characters_count,
inverse_of: :characters
# Validations
validates_presence_of :party
# Validate that uncap_level and transcendence_step are present and numeric.
validates :uncap_level, presence: true, numericality: { only_integer: true }
validates :transcendence_step, presence: true, numericality: { only_integer: true }
validate :validate_awakening_level, on: :update
validate :transcendence, on: :update
validate :validate_over_mastery_values, on: :update
validate :validate_aetherial_mastery_value, on: :update
validate :over_mastery_attack_matches_hp, on: :update
# Virtual attribute for the new rings structure
# Virtual attributes
attr_accessor :new_rings
# Virtual attribute for the new awakening structure
attr_accessor :new_awakening
##### Amoeba configuration
@ -31,52 +50,121 @@ class GridCharacter < ApplicationRecord
set perpetuity: false
end
# Hooks
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
##
# Validates the awakening level to ensure it falls within the allowed range.
#
# @note Triggered on update.
# @return [void]
def validate_awakening_level
errors.add(:awakening, 'awakening level too low') if awakening_level < 1
errors.add(:awakening, 'awakening level too high') if awakening_level > 9
end
##
# Validates the transcendence step of the character.
#
# Ensures that the transcendence step is appropriate based on the character's ULB status.
# Adds errors if:
# - The character has a positive transcendence_step but no transcendence (ulb is false).
# - The transcendence_step exceeds the allowed maximum.
# - The transcendence_step is negative when character.ulb is true.
#
# @note Triggered on update.
# @return [void]
def transcendence
errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb
errors.add(:transcendence_step, 'transcendence step too high') if transcendence_step > 5 && character.ulb
errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb
end
##
# Validates the over mastery attack value for ring1.
#
# Checks that if ring1's modifier is set, the strength must be one of the allowed attack values.
# Adds an error if the value is not valid.
#
# @return [void]
def over_mastery_attack
errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength'])
end
##
# Validates the over mastery HP value for ring2.
#
# If ring2's modifier is present, ensures that the strength is within the allowed HP values.
# Adds an error if the value is not valid.
#
# @return [void]
def over_mastery_hp
return if ring2['modifier'].nil?
errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength'])
end
def over_mastery_attack_matches_hp
return if ring1[:modifier].nil? && ring2[:modifier].nil?
return if ring2[:strength] == (ring1[:strength] / 2)
errors.add(:over_mastery,
'over mastery attack and hp values do not match')
##
# Validates over mastery values by invoking individual and cross-field validations.
#
# This method triggers:
# - Validation for individual over mastery values for rings 1-4.
# - Validation ensuring that ring1's attack and ring2's HP values are consistent.
#
# @return [void]
def validate_over_mastery_values
validate_individual_over_mastery_values
validate_over_mastery_attack_matches_hp
end
def validate_over_mastery_values
##
# Validates individual over mastery values for each ring (ring1 to ring4).
#
# Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength
# is within the permitted range based on over mastery rules.
#
# @return [void]
def validate_individual_over_mastery_values
# Iterate over rings 1-4 and check each rings value.
[ring1, ring2, ring3, ring4].each_with_index do |ring, index|
next if ring['modifier'].nil?
modifier = over_mastery_modifiers[ring['modifier']]
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } },
'over_mastery')
# Use a helper to add errors if the value is out-of-range.
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
end
end
##
# Validates that the over mastery attack value matches the HP value appropriately.
#
# Converts ring1 and ring2 hashes to use indifferent access, and if either ring has a modifier set,
# checks that ring2's strength is exactly half of ring1's strength.
# Adds an error if the values do not match.
#
# @return [void]
def validate_over_mastery_attack_matches_hp
# Convert ring1 and ring2 to use indifferent access so that keys (symbols or strings)
# can be accessed uniformly.
r1 = ring1.with_indifferent_access
r2 = ring2.with_indifferent_access
# Only check if either ring has a modifier set.
if r1[:modifier].present? || r2[:modifier].present?
# Ensure that ring2's strength equals exactly half of ring1's strength.
unless r2[:strength].to_f == (r1[:strength].to_f / 2)
errors.add(:over_mastery, 'over mastery attack and hp values do not match')
end
end
end
##
# Validates the aetherial mastery value for the earring.
#
# If the earring's modifier is present and positive, it uses a helper method to check that the strength
# falls within the allowed range for aetherial mastery.
#
# @return [void]
def validate_aetherial_mastery_value
return if earring['modifier'].nil?
@ -87,22 +175,40 @@ class GridCharacter < ApplicationRecord
'aetherial_mastery')
end
##
# Returns the blueprint for rendering the grid character.
#
# @return [GridCharacterBlueprint] the blueprint class used for grid character representation.
def blueprint
GridCharacterBlueprint
end
private
##
# Adds a default awakening to the character before saving if none is set.
#
# Retrieves the Awakening record with slug 'character-balanced' and assigns it.
#
# @return [void]
def add_awakening
return unless awakening.nil?
self.awakening = Awakening.where(slug: 'character-balanced').sole
end
##
# Applies new ring configurations from the virtual attribute +new_rings+.
#
# Expects +new_rings+ to be an array of hashes with keys "modifier" and "strength".
# Pads the array with default ring hashes to ensure there are exactly four rings, then assigns them to
# ring1, ring2, ring3, and ring4.
#
# @return [void]
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 }
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)
@ -112,11 +218,29 @@ class GridCharacter < ApplicationRecord
self.ring4 = rings_array[3]
end
##
# Applies new awakening configuration from the virtual attribute +new_awakening+.
#
# Sets the +awakening_id+ and +awakening_level+ based on the provided hash.
#
# @return [void]
def apply_new_awakening
self.awakening_id = new_awakening[:id]
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
end
##
# Checks that a given property value falls within the allowed range based on the specified mastery type.
#
# The +property+ parameter is expected to be a hash in the following format:
# { ring1: { atk: 300 } }
#
# Depending on the +type+, it validates against either over mastery or aetherial mastery values.
# Adds an error to the record if the value is not within the permitted range.
#
# @param property [Hash] the property hash containing the attribute and its value.
# @param type [String] the type of mastery validation to perform ('over_mastery' or 'aetherial_mastery').
# @return [void]
def check_value(property, type)
# Input format
# { ring1: { atk: 300 } }
@ -135,6 +259,10 @@ class GridCharacter < ApplicationRecord
end
end
##
# Returns a hash mapping over mastery modifier keys to their corresponding attribute names.
#
# @return [Hash{Integer => String}] mapping of modifier codes to attribute names.
def over_mastery_modifiers
{
1 => 'atk',
@ -155,6 +283,10 @@ class GridCharacter < ApplicationRecord
}
end
##
# Returns a hash containing allowed values for over mastery attributes.
#
# @return [Hash{Symbol => Array<Integer>}] mapping of attribute names to their valid values.
def over_mastery_values
{
atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
@ -175,6 +307,9 @@ class GridCharacter < ApplicationRecord
}
end
# Returns a hash mapping aetherial mastery modifier keys to their corresponding attribute names.
#
# @return [Hash{Integer => String}] mapping of aetherial mastery modifier codes to attribute names.
def aetherial_mastery_modifiers
{
1 => 'da',
@ -190,6 +325,10 @@ class GridCharacter < ApplicationRecord
}
end
##
# Returns a hash containing allowed values for aetherial mastery attributes.
#
# @return [Hash{Symbol => Hash{Symbol => Integer}}] mapping of attribute names to their minimum and maximum values.
def aetherial_mastery_values
{
da: {
@ -235,10 +374,18 @@ class GridCharacter < ApplicationRecord
}
end
##
# Returns an array of valid attack values for over mastery validation.
#
# @return [Array<Integer>] list of allowed attack values.
def atk_values
[300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]
end
##
# Returns an array of valid HP values for over mastery validation.
#
# @return [Array<Integer>] list of allowed HP values.
def hp_values
[150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500]
end

View file

@ -1,5 +1,15 @@
# frozen_string_literal: true
##
# Model representing a grid summon within a party.
#
# A GridSummon is associated with a specific {Summon} and {Party} and is responsible for
# enforcing rules on positions, uncap levels, and transcendence steps based on the associated summons flags.
#
# @!attribute [r] summon
# @return [Summon] the associated summon.
# @!attribute [r] party
# @return [Party] the associated party.
class GridSummon < ApplicationRecord
belongs_to :summon, foreign_key: :summon_id, primary_key: :id
@ -8,14 +18,35 @@ class GridSummon < ApplicationRecord
inverse_of: :summons
validates_presence_of :party
# Validate that position is provided.
validates :position, presence: true
validate :compatible_with_position, on: :create
# Validate that uncap_level and transcendence_step are present and numeric.
validates :uncap_level, presence: true, numericality: { only_integer: true }
validates :transcendence_step, presence: true, numericality: { only_integer: true }
# Custom validation to enforce maximum uncap_level based on the associated Summons flags.
validate :validate_uncap_level_based_on_summon_flags
validate :no_conflicts, on: :create
##
# Returns the blueprint for rendering the grid summon.
#
# @return [GridSummonBlueprint] the blueprint class for grid summons.
def blueprint
GridSummonBlueprint
end
# Returns conflicting summons if they exist
##
# Returns any conflicting grid summon for the given party.
#
# If the associated summon has a limit, this method searches the party's grid summons to find
# any that conflict based on the summon ID.
#
# @param party [Party] the party in which to check for conflicts.
# @return [GridSummon, nil] the conflicting grid summon if found, otherwise nil.
def conflicts(party)
return unless summon.limit
@ -28,13 +59,74 @@ class GridSummon < ApplicationRecord
private
# Validates whether there is a conflict with the party
##
# Validates the uncap_level based on the associated Summons flags.
#
# This method delegates to specific validation methods for FLB, ULB, and transcendence limits.
#
# @return [void]
def validate_uncap_level_based_on_summon_flags
return unless summon
validate_flb_limit
validate_ulb_limit
validate_transcendence_limits
end
##
# Validates that the uncap_level does not exceed 3 if the associated Summon does not have the FLB flag.
#
# @return [void]
def validate_flb_limit
return unless !summon.flb && uncap_level.to_i > 3
errors.add(:uncap_level, 'cannot be greater than 3 if summon does not have FLB')
end
##
# Validates that the uncap_level does not exceed 4 if the associated Summon does not have the ULB flag.
#
# @return [void]
def validate_ulb_limit
return unless !summon.ulb && uncap_level.to_i > 4
errors.add(:uncap_level, 'cannot be greater than 4 if summon does not have ULB')
end
##
# Validates the uncap_level and transcendence_step based on whether the associated Summon supports transcendence.
#
# If the summon does not support transcendence, the uncap_level must not exceed 5 and the transcendence_step must be 0.
#
# @return [void]
def validate_transcendence_limits
return if summon.transcendence
errors.add(:uncap_level, 'cannot be greater than 5 if summon does not have transcendence') if uncap_level.to_i > 5
return unless transcendence_step.to_i.positive?
errors.add(:transcendence_step, 'must be 0 if summon does not have transcendence')
end
##
# Validates that there are no conflicting grid summons in the party.
#
# If a conflict is found (i.e. another grid summon exists that conflicts with this one),
# an error is added to the :series attribute.
#
# @return [void]
def no_conflicts
# Check if the grid summon conflicts with any of the other grid summons in the party
errors.add(:series, 'must not conflict with existing summons') unless conflicts(party).nil?
end
# Validates whether the summon can be added to the desired position
##
# Validates whether the grid summon can be added to the desired position.
#
# For positions 4 and 5, the associated summon must have subaura; otherwise, an error is added.
#
# @return [void]
def compatible_with_position
return unless [4, 5].include?(position.to_i) && !summon.subaura

View file

@ -1,6 +1,30 @@
# frozen_string_literal: true
##
# Model representing a grid weapon within a party.
#
# This model associates a weapon with a party and manages validations for weapon compatibility,
# conflict detection, and attribute adjustments such as determining if a weapon is mainhand.
#
# @!attribute [r] weapon
# @return [Weapon] the associated weapon.
# @!attribute [r] party
# @return [Party] the party to which the grid weapon belongs.
# @!attribute [r] weapon_key1
# @return [WeaponKey, nil] the primary weapon key, if assigned.
# @!attribute [r] weapon_key2
# @return [WeaponKey, nil] the secondary weapon key, if assigned.
# @!attribute [r] weapon_key3
# @return [WeaponKey, nil] the tertiary weapon key, if assigned.
# @!attribute [r] weapon_key4
# @return [WeaponKey, nil] the quaternary weapon key, if assigned.
# @!attribute [r] awakening
# @return [Awakening, nil] the associated awakening, if any.
class GridWeapon < ApplicationRecord
# Allowed extra positions and allowed weapon series when in an extra position.
EXTRA_POSITIONS = [9, 10, 11].freeze
ALLOWED_EXTRA_SERIES = [11, 16, 17, 28, 29, 32, 34].freeze
belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id
belongs_to :party,
@ -15,10 +39,14 @@ class GridWeapon < ApplicationRecord
belongs_to :awakening, optional: true
# Validate that uncap_level and transcendence_step are present and numeric.
validates :uncap_level, presence: true, numericality: { only_integer: true }
validates :transcendence_step, presence: true, numericality: { only_integer: true }
validate :compatible_with_position, on: :create
validate :no_conflicts, on: :create
before_save :mainhand?
before_save :assign_mainhand
##### Amoeba configuration
amoeba do
@ -28,69 +56,99 @@ class GridWeapon < ApplicationRecord
nullify :ax_strength2
end
# Helper methods
##
# Returns the blueprint for rendering the grid weapon.
#
# @return [GridWeaponBlueprint] the blueprint class for grid weapons.
def blueprint
GridWeaponBlueprint
end
##
# Returns an array of assigned weapon keys.
#
# This method returns an array containing weapon_key1, weapon_key2, and weapon_key3,
# omitting any nil values.
#
# @return [Array<WeaponKey>] the non-nil weapon keys.
def weapon_keys
[weapon_key1, weapon_key2, weapon_key3].compact
end
# Returns conflicting weapons if they exist
##
# Returns conflicting grid weapons within a given party.
#
# Checks if the associated weapon is present, responds to a :limit method, and is limited.
# It then iterates over the party's grid weapons and selects those that conflict with this one,
# based on series matching or specific conditions related to opus or draconic status.
#
# @param party [Party] the party in which to check for conflicts.
# @return [ActiveRecord::Relation<GridWeapon>] an array of conflicting grid weapons (empty if none are found).
def conflicts(party)
return unless weapon.limit
return [] unless weapon.present? && weapon.respond_to?(:limit) && weapon.limit
conflicting_weapons = []
party.weapons.each do |party_weapon|
next unless party_weapon.id
party.weapons.select do |party_weapon|
# Skip if the record is not persisted.
next false unless party_weapon.id.present?
id_match = weapon.id == party_weapon.id
series_match = weapon.series == party_weapon.weapon.series
both_opus_or_draconic = weapon.opus_or_draconic? && party_weapon.weapon.opus_or_draconic?
both_draconic = weapon.draconic_or_providence? && party_weapon.weapon.draconic_or_providence?
conflicting_weapons << party_weapon if (series_match || both_opus_or_draconic || both_draconic) && !id_match
(series_match || both_opus_or_draconic || both_draconic) && !id_match
end
conflicting_weapons
end
private
# Conflict management methods
# Validates whether the weapon can be added to the desired position
##
# Validates whether the grid weapon is compatible with the desired position.
#
# For positions 9, 10, or 11 (considered extra positions), the weapon's series must belong to the allowed set.
# If the weapon is in an extra position but does not match an allowed series, an error is added.
#
# @return [void]
def compatible_with_position
is_extra_position = [9, 10, 11].include?(position.to_i)
is_extra_weapon = [11, 16, 17, 28, 29, 32, 34].include?(weapon.series.to_i)
return unless is_extra_position
return true if is_extra_weapon
return unless weapon.present?
if EXTRA_POSITIONS.include?(position.to_i) && !ALLOWED_EXTRA_SERIES.include?(weapon.series.to_i)
errors.add(:series, 'must be compatible with position')
false
end
end
# Validates whether the desired weapon key can be added to the weapon
##
# Validates that the assigned weapon keys are compatible with the weapon.
#
# Iterates over each non-nil weapon key and checks compatibility using the weapon's
# `compatible_with_key?` method. An error is added for any key that is not compatible.
#
# @return [void]
def compatible_with_key
weapon_keys.each do |key|
errors.add(:weapon_keys, 'must be compatible with weapon') unless weapon.compatible_with_key?(key)
end
end
# Validates whether there is a conflict with the party
##
# Validates that there are no conflicting grid weapons in the party.
#
# Checks if the current grid weapon conflicts with any other grid weapons within the party.
# If conflicting weapons are found, an error is added.
#
# @return [void]
def no_conflicts
# Check if the grid weapon conflicts with any of the other grid weapons in the party
return unless !conflicts(party).nil? && !conflicts(party).empty?
errors.add(:series, 'must not conflict with existing weapons')
conflicting = conflicts(party)
errors.add(:series, 'must not conflict with existing weapons') if conflicting.any?
end
# Checks if the weapon should be a mainhand before saving the model
def mainhand?
self.mainhand = position == -1
##
# Determines if the grid weapon should be marked as mainhand based on its position.
#
# If the grid weapon's position is -1, sets the `mainhand` attribute to true.
#
# @return [void]
def assign_mainhand
self.mainhand = (position == -1)
end
end

View file

@ -1,7 +1,86 @@
# frozen_string_literal: true
##
# This file defines the Party model which represents a party in the application.
# It encapsulates the logic for managing party records including associations with
# characters, weapons, summons, and other related models. The Party model handles
# validations, nested attributes, preview generation, and various business logic
# to ensure consistency and integrity of party data.
#
# @note The model uses ActiveRecord associations, enums, and custom validations.
#
# @!attribute [rw] preview_state
# @return [Integer] the current state of the preview, represented as an enum:
# - 0: pending
# - 1: queued
# - 2: in_progress
# - 3: generated
# - 4: failed
# @!attribute [rw] element
# @return [Integer] the elemental type associated with the party.
# @!attribute [rw] clear_time
# @return [Integer] the clear time for the party.
# @!attribute [rw] master_level
# @return [Integer, nil] the master level of the party.
# @!attribute [rw] button_count
# @return [Integer, nil] the button count, if applicable.
# @!attribute [rw] chain_count
# @return [Integer, nil] the chain count, if applicable.
# @!attribute [rw] turn_count
# @return [Integer, nil] the turn count, if applicable.
# @!attribute [rw] ultimate_mastery
# @return [Integer, nil] the ultimate mastery level, if applicable.
# @!attribute [rw] visibility
# @return [Integer] the visibility of the party:
# - 1: Public
# - 2: Unlisted
# - 3: Private
# @!attribute [rw] shortcode
# @return [String] a unique shortcode for the party.
# @!attribute [rw] edit_key
# @return [String] an edit key for parties without an associated user.
#
# @!attribute [r] source_party
# @return [Party, nil] the original party if this is a remix.
# @!attribute [r] remixes
# @return [Array<Party>] a collection of parties remixed from this party.
# @!attribute [r] user
# @return [User, nil] the user who created the party.
# @!attribute [r] raid
# @return [Raid, nil] the associated raid.
# @!attribute [r] job
# @return [Job, nil] the associated job.
# @!attribute [r] accessory
# @return [JobAccessory, nil] the accessory used in the party.
# @!attribute [r] skill0
# @return [JobSkill, nil] the primary skill.
# @!attribute [r] skill1
# @return [JobSkill, nil] the secondary skill.
# @!attribute [r] skill2
# @return [JobSkill, nil] the tertiary skill.
# @!attribute [r] skill3
# @return [JobSkill, nil] the quaternary skill.
# @!attribute [r] guidebook1
# @return [Guidebook, nil] the first guidebook.
# @!attribute [r] guidebook2
# @return [Guidebook, nil] the second guidebook.
# @!attribute [r] guidebook3
# @return [Guidebook, nil] the third guidebook.
# @!attribute [r] characters
# @return [Array<GridCharacter>] the characters associated with this party.
# @!attribute [r] weapons
# @return [Array<GridWeapon>] the weapons associated with this party.
# @!attribute [r] summons
# @return [Array<GridSummon>] the summons associated with this party.
# @!attribute [r] favorites
# @return [Array<Favorite>] the favorites that include this party.
class Party < ApplicationRecord
##### ActiveRecord Associations
include GranblueEnums
# Define preview_state as an enum.
enum :preview_state, { pending: 0, queued: 1, in_progress: 2, generated: 3, failed: 4 }
# ActiveRecord Associations
belongs_to :source_party,
class_name: 'Party',
foreign_key: :source_party_id,
@ -87,7 +166,10 @@ class Party < ApplicationRecord
before_create :set_shortcode
before_create :set_edit_key
##### Amoeba configuration
after_commit :update_element!, on: %i[create update]
after_commit :update_extra!, on: %i[create update]
# Amoeba configuration
amoeba do
set weapons_count: 0
set characters_count: 0
@ -102,45 +184,86 @@ class Party < ApplicationRecord
include_association :summons
end
##### ActiveRecord Validations
# ActiveRecord Validations
validate :skills_are_unique
validate :guidebooks_are_unique
self.enum :preview_state, {
pending: 0,
queued: 1,
in_progress: 2,
generated: 3,
failed: 4
# For element, validate numericality and inclusion using the allowed values from GranblueEnums.
validates :element,
numericality: { only_integer: true },
inclusion: {
in: GranblueEnums::ELEMENTS.values,
message: "must be one of #{GranblueEnums::ELEMENTS.map { |name, value| "#{value} (#{name})" }.join(', ')}"
},
allow_nil: true
validates :clear_time, numericality: { only_integer: true }
validates :master_level, numericality: { only_integer: true }, allow_nil: true
validates :button_count, numericality: { only_integer: true }, allow_nil: true
validates :chain_count, numericality: { only_integer: true }, allow_nil: true
validates :turn_count, numericality: { only_integer: true }, allow_nil: true
validates :ultimate_mastery, numericality: { only_integer: true }, allow_nil: true
# Validate visibility (allowed values: 1, 2, or 3).
validates :visibility,
numericality: { only_integer: true },
inclusion: {
in: [1, 2, 3],
message: 'must be 1 (Public), 2 (Unlisted), or 3 (Private)'
}
after_commit :schedule_preview_generation, if: :should_generate_preview?
def is_remix
#########################
# Public API Methods
#########################
##
# Checks if the party is a remix of another party.
#
# @return [Boolean] true if the party is a remix; false otherwise.
def remix?
!source_party.nil?
end
def remixes
Party.where(source_party_id: id)
end
##
# Returns the blueprint class used for rendering the party.
#
# @return [Class] the PartyBlueprint class.
def blueprint
PartyBlueprint
end
##
# Determines if the party is public.
#
# @return [Boolean] true if the party is public; false otherwise.
def public?
visibility == 1
end
##
# Determines if the party is unlisted.
#
# @return [Boolean] true if the party is unlisted; false otherwise.
def unlisted?
visibility == 2
end
##
# Determines if the party is private.
#
# @return [Boolean] true if the party is private; false otherwise.
def private?
visibility == 3
end
def is_favorited(user)
##
# Checks if the party is favorited by a given user.
#
# @param user [User, nil] the user to check for favoritism.
# @return [Boolean] true if the party is favorited by the user; false otherwise.
def favorited?(user)
return false unless user
Rails.cache.fetch("party_#{id}_favorited_by_#{user.id}", expires_in: 1.hour) do
@ -148,99 +271,216 @@ class Party < ApplicationRecord
end
end
##
# Determines if the party meets the minimum requirements for preview generation.
#
# The party must have at least one weapon, one character, and one summon.
#
# @return [Boolean] true if the party is ready for preview; false otherwise.
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
##
# Determines whether a new preview should be generated for the party.
#
# The method checks various conditions such as preview state, expiration, and content changes.
#
# @return [Boolean] true if a preview generation should be triggered; false otherwise.
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
return true if preview_pending?
return true if preview_failed_and_stale?
return true if preview_generated_and_expired?
return true if preview_content_changed_and_stale?
false
end
##
# Checks whether the current preview has expired based on a predefined expiry period.
#
# @return [Boolean] true if the preview is expired; false otherwise.
def preview_expired?
preview_generated_at.nil? ||
preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago
end
##
# Determines if the content relevant for preview generation has changed.
#
# @return [Boolean] true if any preview-relevant attributes have changed; false otherwise.
def preview_content_changed?
saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) }
end
##
# Schedules the generation of a party preview if applicable.
#
# This method updates the preview state to 'queued' and enqueues a background job
# to generate the preview.
#
# @return [void]
def schedule_preview_generation
return if preview_state == 'queued' || preview_state == 'in_progress'
return if %w[queued in_progress].include?(preview_state.to_s)
update_column(:preview_state, 'queued')
update_column(:preview_state, self.class.preview_states[:queued])
GeneratePartyPreviewJob.perform_later(id)
end
private
def set_shortcode
self.shortcode = random_string
#########################
# Preview Generation Helpers
#########################
##
# Checks if the preview is pending.
#
# @return [Boolean] true if preview_state is nil or 'pending'.
def preview_pending?
preview_state.nil? || preview_state == 'pending'
end
def set_edit_key
return if user
self.edit_key = Digest::SHA1.hexdigest([Time.now, rand].join)
##
# Checks if the preview generation failed and the preview is stale.
#
# @return [Boolean] true if preview_state is 'failed' and preview_generated_at is older than 5 minutes.
def preview_failed_and_stale?
preview_state == 'failed' && preview_generated_at < 5.minutes.ago
end
def random_string
num_chars = 6
o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
(0...num_chars).map { o[rand(o.length)] }.join
##
# Checks if the generated preview is expired.
#
# @return [Boolean] true if preview_state is 'generated' and the preview is expired.
def preview_generated_and_expired?
preview_state == 'generated' && preview_expired?
end
##
# Checks if the preview content has changed and the preview is stale.
#
# @return [Boolean] true if the preview content has changed and preview_generated_at is nil or older than 5 minutes.
def preview_content_changed_and_stale?
preview_content_changed? && (preview_generated_at.nil? || preview_generated_at < 5.minutes.ago)
end
#########################
# Uniqueness Validation Helpers
#########################
##
# Validates uniqueness for a given set of associations.
#
# @param associations [Array<Object, nil>] an array of associated objects.
# @param attribute_names [Array<Symbol>] the corresponding attribute names for each association.
# @param error_key [Symbol] the key for a generic error.
# @return [void]
def validate_uniqueness_of_associations(associations, attribute_names, error_key)
filtered = associations.compact
return if filtered.uniq.length == filtered.length
associations.each_with_index do |assoc, index|
next if assoc.nil?
errors.add(attribute_names[index], 'must be unique') if associations[0...index].include?(assoc)
end
errors.add(error_key, 'must be unique')
end
##
# Validates that the selected skills are unique.
#
# @return [void]
def skills_are_unique
skills = [skill0, skill1, skill2, skill3].compact
return if skills.uniq.length == skills.length
skills.each_with_index do |skill, index|
next if index.zero?
errors.add(:"skill#{index + 1}", 'must be unique') if skills[0...index].include?(skill)
end
errors.add(:job_skills, 'must be unique')
validate_uniqueness_of_associations([skill0, skill1, skill2, skill3],
[:skill0, :skill1, :skill2, :skill3],
:job_skills)
end
##
# Validates that the selected guidebooks are unique.
#
# @return [void]
def guidebooks_are_unique
guidebooks = [guidebook1, guidebook2, guidebook3].compact
return if guidebooks.uniq.length == guidebooks.length
guidebooks.each_with_index do |book, index|
next if index.zero?
errors.add(:"guidebook#{index + 1}", 'must be unique') if guidebooks[0...index].include?(book)
end
errors.add(:guidebooks, 'must be unique')
validate_uniqueness_of_associations([guidebook1, guidebook2, guidebook3],
[:guidebook1, :guidebook2, :guidebook3],
:guidebooks)
end
##
# Provides a list of attributes that are relevant for determining if the preview content has changed.
#
# @return [Array<String>] an array of attribute names.
def preview_relevant_attributes
%w[
name job_id element weapons_count characters_count summons_count
full_auto auto_guard charge_attack clear_time
]
end
#########################
# Miscellaneous Helpers
#########################
##
# Updates the party's element based on its main weapon.
#
# Finds the main weapon (position -1) and updates the party's element if it differs.
#
# @return [void]
def update_element!
main_weapon = weapons.detect { |gw| gw.position.to_i == -1 }
new_element = main_weapon&.weapon&.element
update_column(:element, new_element) if new_element.present? && self.element != new_element
end
##
# Updates the party's extra flag based on weapon positions.
#
# Sets the extra flag to true if any weapon is in an extra position, otherwise false.
#
# @return [void]
def update_extra!
new_extra = weapons.any? { |gw| GridWeapon::EXTRA_POSITIONS.include?(gw.position.to_i) }
update_column(:extra, new_extra) if self.extra != new_extra
end
##
# Sets a unique shortcode for the party before creation.
#
# Generates a random string and assigns it to the shortcode attribute.
#
# @return [void]
def set_shortcode
self.shortcode = random_string
end
##
# Sets an edit key for the party before creation if no associated user is present.
#
# The edit key is generated using a SHA1 hash based on the current time and a random value.
#
# @return [void]
def set_edit_key
return if user
self.edit_key ||= Digest::SHA1.hexdigest([Time.now, rand].join)
end
##
# Generates a random alphanumeric string used for the party shortcode.
#
# @return [String] a random string of 6 characters.
def random_string
num_chars = 6
o = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
(0...num_chars).map { o[rand(o.length)] }.join
end
end

View file

@ -7,9 +7,7 @@ module PreviewService
PREVIEW_EXPIRY = 30.days
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

View file

@ -34,6 +34,8 @@ module HenseiApi
config.active_record.query_log_tags = [:application, :controller, :action, :job]
config.active_record.cache_query_log_tags = true
config.active_support.to_time_preserves_timezone = :zone
# API-only application configuration
config.api_only = true
end

View file

@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time"
require 'active_support/core_ext/integer/time'
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
@ -9,17 +9,17 @@ Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Turn false under Spring and add config.action_view.cache_template_loading = true.
config.cache_classes = true
config.cache_classes = false
# Eager loading loads your whole application. When running a single test locally,
# this probably isn't necessary. It's a good idea to do in a continuous integration
# system, or in some way before deploying your code.
config.eager_load = ENV["CI"].present?
config.eager_load = ENV['CI'].present?
# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
'Cache-Control' => "public, max-age=#{1.hour.to_i}"
}
# Show full error reports and disable caching.

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Explicitly configure Oj to mimic JSON.
Oj::Rails.mimic_JSON

View file

@ -25,6 +25,7 @@ Rails.application.routes.draw do
get 'parties/favorites', to: 'parties#favorites'
get 'parties/:id', to: 'parties#show'
get 'parties/:id/preview', to: 'parties#preview'
get 'parties/:id/preview_status', to: 'parties#preview_status'
post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview'
post 'parties/:id/remix', to: 'parties#remix'

41
db/seed/canonical.rb Normal file
View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'csv'
# Helper: Process boolean columns.
def process_booleans(attrs, columns)
columns.each do |col|
next unless attrs.key?(col) && attrs[col].present?
attrs[col] = ActiveModel::Type::Boolean.new.cast(attrs[col])
end
end
# Helper: Process date columns.
def process_dates(attrs, columns)
columns.each do |col|
next unless attrs.key?(col) && attrs[col].present?
attrs[col] = Date.parse(attrs[col]) rescue nil
end
end
# Simplified CSV loader for a given model.
def load_csv_for(model_class, csv_filename, unique_key = :granblue_id)
csv_file = Rails.root.join('db', 'seed', 'test', csv_filename)
puts "Loading #{model_class.name} data from #{csv_file}..."
CSV.foreach(csv_file, headers: true) do |row|
attrs = row.to_hash.symbolize_keys
process_booleans(attrs, %i[flb ulb subaura limit transcendence])
process_dates(attrs, %i[release_date flb_date ulb_date transcendence_date])
attrs.each { |k, v| attrs[k] = nil if v.is_a?(String) && v.strip.empty? }
attrs.except!(:id)
model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
r.assign_attributes(attrs.except(unique_key))
end
end
end
# Load canonical data for each model.
load_csv_for(Awakening, 'awakenings_test.csv', :slug)
load_csv_for(Summon, 'summons_test.csv', :granblue_id)
load_csv_for(Weapon, 'weapons_test.csv', :granblue_id)
load_csv_for(Character, 'characters_test.csv', :granblue_id)

View file

@ -0,0 +1,11 @@
"id","name_en","name_jp","slug","object_type","order"
"6e233877-8cda-4c8f-a091-3db6f68749e2","Attack","攻撃","character-atk","Character",1
"b1847c82-ece0-4d7a-8af1-c7868d90f34a","Balanced","バランス","character-balanced","Character",0
"c95441de-f949-4a62-b02b-101aa2e0a638","Defense","防御","character-def","Character",2
"e36b0573-79c3-4dd2-9524-c95def4bbb1a","Multiattack","連続攻撃","character-multi","Character",3
"d691a61c-dc7e-4d92-a8e6-98c04608353c","Attack","攻撃","weapon-atk","Weapon",1
"a60b8356-ec37-4f8b-a188-a3d48803ac76","C.A.","奥義","weapon-ca","Weapon",4
"969d37db-5f14-4d1a-bef4-59ba5a016674","Defense","防御","weapon-def","Weapon",2
"26a47007-8886-476a-b6c0-b56c8fcdb09f","Healing","回復","weapon-heal","Weapon",5
"18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7","Skill DMG","アビダメ","weapon-skill","Weapon",6
"275c9de5-db1e-4c66-8210-660505fd1af4","Special","特殊","weapon-special","Weapon",3
1 id name_en name_jp slug object_type order
2 6e233877-8cda-4c8f-a091-3db6f68749e2 Attack 攻撃 character-atk Character 1
3 b1847c82-ece0-4d7a-8af1-c7868d90f34a Balanced バランス character-balanced Character 0
4 c95441de-f949-4a62-b02b-101aa2e0a638 Defense 防御 character-def Character 2
5 e36b0573-79c3-4dd2-9524-c95def4bbb1a Multiattack 連続攻撃 character-multi Character 3
6 d691a61c-dc7e-4d92-a8e6-98c04608353c Attack 攻撃 weapon-atk Weapon 1
7 a60b8356-ec37-4f8b-a188-a3d48803ac76 C.A. 奥義 weapon-ca Weapon 4
8 969d37db-5f14-4d1a-bef4-59ba5a016674 Defense 防御 weapon-def Weapon 2
9 26a47007-8886-476a-b6c0-b56c8fcdb09f Healing 回復 weapon-heal Weapon 5
10 18ab5007-3fcb-4f83-a7a0-879a9a4a7ad7 Skill DMG アビダメ weapon-skill Weapon 6
11 275c9de5-db1e-4c66-8210-660505fd1af4 Special 特殊 weapon-special Weapon 3

View file

@ -0,0 +1,6 @@
"id","name_en","name_jp","granblue_id","release_date","wiki_en","wiki_ja","rarity","element","proficiency1","proficiency2","gender","race1","race2","flb","min_hp","max_hp","max_hp_flb","min_atk","max_atk","max_atk_flb","base_da","base_ta","ougi_ratio","ougi_ratio_flb","special","ulb","max_hp_ulb","max_atk_ulb","character_id","nicknames_en","nicknames_jp","flb_date","ulb_date","gamewith","kamigame"
"9ad10c6f-83cd-4de3-a1ec-829efe0ac83b","Rosamia (SSR)","ロザミア(SSR)","3040087000","2016-06-30","Rosamia (SSR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SSR%29",3,6,1,,2,1,,FALSE,300,1600,,300,1600,,10,5,4.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"33985","SSR/ロザミア"
"afd282c7-ba4d-4213-b039-4ae7b71ef26e","Rosamia","ロザミア","3020018000","2014-03-10","Rosamia","%A5%ED%A5%B6%A5%DF%A5%A2%20%28R%29",1,6,1,,2,1,,FALSE,210,840,,950,3800,,,,,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21155","R/ロザミアR"
"d5fb1b79-483f-44cf-8437-92ce31f5f2b2","Rosamia (SR)","ロザミア(SR)","3030049000","2014-12-31","Rosamia (SR)","%A5%ED%A5%B6%A5%DF%A5%A2%20%28SR%29",2,6,1,,2,1,,FALSE,260,1300,,260,1300,,7,3,3.5,,FALSE,FALSE,,,"{1018}","{}","{}",,,"21090","SR/ロザミア"
"24bf1c09-509f-4db1-b953-b95ebcc69fb9","Seofon","シエテ","3040036000","2015-04-16","Seofon","%A5%B7%A5%A8%A5%C6%20%28SSR%29",3,1,1,,1,1,,TRUE,237,1477,1777,1777,10777,12777,10,5,4.5,5,FALSE,TRUE,14777,1977,"{4007}","{siete}","{}","2017-03-20","2021-06-29","21117","SSR/シエテ"
"b1eae4fe-e35c-44da-aa4f-7ca1c3e5863f","Zeta","ゼタ","3040028000","2014-12-31","Zeta","%A5%BC%A5%BF%20%28SSR%29",3,2,4,,2,1,,TRUE,240,1280,1520,240,1280,1520,10,5,5,,FALSE,FALSE,,,"{3024}","{}","{}","2020-01-28",,"21290","SSR/ゼタ"
1 id name_en name_jp granblue_id release_date wiki_en wiki_ja rarity element proficiency1 proficiency2 gender race1 race2 flb min_hp max_hp max_hp_flb min_atk max_atk max_atk_flb base_da base_ta ougi_ratio ougi_ratio_flb special ulb max_hp_ulb max_atk_ulb character_id nicknames_en nicknames_jp flb_date ulb_date gamewith kamigame
2 9ad10c6f-83cd-4de3-a1ec-829efe0ac83b Rosamia (SSR) ロザミア(SSR) 3040087000 2016-06-30 Rosamia (SSR) %A5%ED%A5%B6%A5%DF%A5%A2%20%28SSR%29 3 6 1 2 1 FALSE 300 1600 300 1600 10 5 4.5 FALSE FALSE {1018} {} {} 33985 SSR/ロザミア
3 afd282c7-ba4d-4213-b039-4ae7b71ef26e Rosamia ロザミア 3020018000 2014-03-10 Rosamia %A5%ED%A5%B6%A5%DF%A5%A2%20%28R%29 1 6 1 2 1 FALSE 210 840 950 3800 FALSE FALSE {1018} {} {} 21155 R/ロザミア(R)
4 d5fb1b79-483f-44cf-8437-92ce31f5f2b2 Rosamia (SR) ロザミア(SR) 3030049000 2014-12-31 Rosamia (SR) %A5%ED%A5%B6%A5%DF%A5%A2%20%28SR%29 2 6 1 2 1 FALSE 260 1300 260 1300 7 3 3.5 FALSE FALSE {1018} {} {} 21090 SR/ロザミア
5 24bf1c09-509f-4db1-b953-b95ebcc69fb9 Seofon シエテ 3040036000 2015-04-16 Seofon %A5%B7%A5%A8%A5%C6%20%28SSR%29 3 1 1 1 1 TRUE 237 1477 1777 1777 10777 12777 10 5 4.5 5 FALSE TRUE 14777 1977 {4007} {siete} {} 2017-03-20 2021-06-29 21117 SSR/シエテ
6 b1eae4fe-e35c-44da-aa4f-7ca1c3e5863f Zeta ゼタ 3040028000 2014-12-31 Zeta %A5%BC%A5%BF%20%28SSR%29 3 2 4 2 1 TRUE 240 1280 1520 240 1280 1520 10 5 5 FALSE FALSE {3024} {} {} 2020-01-28 21290 SSR/ゼタ

View file

@ -0,0 +1,10 @@
"id","name_en","name_jp","granblue_id","rarity","element","series","flb","ulb","max_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","subaura","limit","transcendence","max_atk_xlb","max_hp_xlb","summon_id","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence_date","nicknames_en","nicknames_jp"
"6db3991a-e72a-41fc-bad5-c7cc6e7f39ff","Orologia","オロロジャイア","2040433000",3,5,"0",TRUE,FALSE,150,145,836,1182,,310,2023,2880,,TRUE,FALSE,FALSE,,,3341,"2024-12-31","2024-12-31",,"Orologia_(Summon)","オロロジャイア (SSR)","479773","オロロジャイア",,"{}","{}"
"eaed64ed-2b1b-4f9f-a572-2032bbff197d","Wedges of the Sky","蒼空の楔","2040430000",3,1,,FALSE,FALSE,100,109,668,,,277,1740,,,TRUE,FALSE,FALSE,,,3338,"2024-08-09",,,"Wedges_of_the_Sky","蒼空の楔 (SSR)","458492","蒼空の楔",,"{boost,dragons}","{}"
"7d303689-3eb1-4193-80cd-e15de46af9d4","Beelzebub (Summer)","ベルゼバブ(水着ver)","2040429000",3,3,,TRUE,FALSE,150,115,793,1132,,442,2137,2985,,TRUE,FALSE,FALSE,,,3319,"2024-07-31","2024-07-31",,"Beelzebub_(Summer)","ベルゼバブ (SSR)水着","458158","水着ベルゼバブ",,"{babu,bubz}","{}"
"d169eaba-5493-45d4-a2f8-966850d784c9","Lu Woh","ル・オー","2040409000",3,6,"12",TRUE,FALSE,100,155,880,1243,,255,1800,2573,,TRUE,FALSE,FALSE,,,,"2023-02-28",,,"Lu Woh","%A5%EB%A1%A6%A5%AA%A1%BC%20%28SSR%29","385068","SSR/ルオー",,"{}","{}"
"42899ba7-dbf4-433f-8dd4-805010a1a627","Yatima","ヤチマ","2040417000",3,3,"0",TRUE,FALSE,150,138,1155,,,327,2870,,,TRUE,TRUE,FALSE,,,,"2022-12-31","2022-12-31",,"Yatima","%BE%A4%B4%AD%C0%D0%2F%A5%E4%A5%C1%A5%DE%20%28SSR%29","381024","SSR/ヤチマ",,"{}","{}"
"ad9abfc0-d919-4f75-aa15-0993d20d85ba","Beelzebub","ベルゼバブ","2040408000",3,5,"0",TRUE,FALSE,150,122,761,1081,,407,2317,3272,,TRUE,FALSE,FALSE,,,,"2021-12-31","2021-12-31",,"Beelzebub","%BE%A4%B4%AD%C0%D0%2F%A5%D9%A5%EB%A5%BC%A5%D0%A5%D6%20%28SSR%29","314288","ベルゼバブ",,"{bubz,bubs}","{}"
"0156d098-f5e7-4d72-8d14-34e6adff9280","The Moon","ザ・ムーン","2040243000",3,3,"6",TRUE,TRUE,200,110,668,952,1094,110,668,952,1094,TRUE,FALSE,FALSE,,,,"2017-11-29","2018-03-22","2019-03-10","The Moon (SSR)","%BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%E0%A1%BC%A5%F3%20%28SSR%29","81835","SSR/ザ・ムーン",,"{}","{}"
"05214d59-2765-40c3-9a1d-6c29c6bdc6d6","Colossus Omega","コロッサス・マグナ","2040034000",3,2,"2",TRUE,TRUE,200,103,648,778,921,275,1635,1965,2315,TRUE,FALSE,TRUE,2665,1064,2001,"2014-10-08","2018-03-10","2020-08-08","Colossus Omega","%BE%A4%B4%AD%C0%D0%2F%A5%B3%A5%ED%A5%C3%A5%B5%A5%B9%A1%A6%A5%DE%A5%B0%A5%CA%20%28SSR%29","21736","SSR/コロッサス・マグナ","2024-05-02","{}","{}"
"d27d0d9a-3b38-4dcd-89a5-4016c2906249","Bahamut","バハムート","2040003000",3,5,"0",TRUE,TRUE,200,140,850,1210,1390,140,850,1210,1390,TRUE,FALSE,TRUE,,,,"2014-04-30","2017-03-10","2019-03-22","Bahamut","%BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29","21612","SSR/バハムート",,"{}","{}"
1 id name_en name_jp granblue_id rarity element series flb ulb max_level min_hp max_hp max_hp_flb max_hp_ulb min_atk max_atk max_atk_flb max_atk_ulb subaura limit transcendence max_atk_xlb max_hp_xlb summon_id release_date flb_date ulb_date wiki_en wiki_ja gamewith kamigame transcendence_date nicknames_en nicknames_jp
2 6db3991a-e72a-41fc-bad5-c7cc6e7f39ff Orologia オロロジャイア 2040433000 3 5 0 TRUE FALSE 150 145 836 1182 310 2023 2880 TRUE FALSE FALSE 3341 2024-12-31 2024-12-31 Orologia_(Summon) オロロジャイア (SSR) 479773 オロロジャイア {} {}
3 eaed64ed-2b1b-4f9f-a572-2032bbff197d Wedges of the Sky 蒼空の楔 2040430000 3 1 FALSE FALSE 100 109 668 277 1740 TRUE FALSE FALSE 3338 2024-08-09 Wedges_of_the_Sky 蒼空の楔 (SSR) 458492 蒼空の楔 {boost,dragons} {}
4 7d303689-3eb1-4193-80cd-e15de46af9d4 Beelzebub (Summer) ベルゼバブ(水着ver) 2040429000 3 3 TRUE FALSE 150 115 793 1132 442 2137 2985 TRUE FALSE FALSE 3319 2024-07-31 2024-07-31 Beelzebub_(Summer) ベルゼバブ (SSR)水着 458158 水着ベルゼバブ {babu,bubz} {}
5 d169eaba-5493-45d4-a2f8-966850d784c9 Lu Woh ル・オー 2040409000 3 6 12 TRUE FALSE 100 155 880 1243 255 1800 2573 TRUE FALSE FALSE 2023-02-28 Lu Woh %A5%EB%A1%A6%A5%AA%A1%BC%20%28SSR%29 385068 SSR/ルオー {} {}
6 42899ba7-dbf4-433f-8dd4-805010a1a627 Yatima ヤチマ 2040417000 3 3 0 TRUE FALSE 150 138 1155 327 2870 TRUE TRUE FALSE 2022-12-31 2022-12-31 Yatima %BE%A4%B4%AD%C0%D0%2F%A5%E4%A5%C1%A5%DE%20%28SSR%29 381024 SSR/ヤチマ {} {}
7 ad9abfc0-d919-4f75-aa15-0993d20d85ba Beelzebub ベルゼバブ 2040408000 3 5 0 TRUE FALSE 150 122 761 1081 407 2317 3272 TRUE FALSE FALSE 2021-12-31 2021-12-31 Beelzebub %BE%A4%B4%AD%C0%D0%2F%A5%D9%A5%EB%A5%BC%A5%D0%A5%D6%20%28SSR%29 314288 ベルゼバブ {bubz,bubs} {}
8 0156d098-f5e7-4d72-8d14-34e6adff9280 The Moon ザ・ムーン 2040243000 3 3 6 TRUE TRUE 200 110 668 952 1094 110 668 952 1094 TRUE FALSE FALSE 2017-11-29 2018-03-22 2019-03-10 The Moon (SSR) %BE%A4%B4%AD%C0%D0%2F%A5%B6%A1%A6%A5%E0%A1%BC%A5%F3%20%28SSR%29 81835 SSR/ザ・ムーン {} {}
9 05214d59-2765-40c3-9a1d-6c29c6bdc6d6 Colossus Omega コロッサス・マグナ 2040034000 3 2 2 TRUE TRUE 200 103 648 778 921 275 1635 1965 2315 TRUE FALSE TRUE 2665 1064 2001 2014-10-08 2018-03-10 2020-08-08 Colossus Omega %BE%A4%B4%AD%C0%D0%2F%A5%B3%A5%ED%A5%C3%A5%B5%A5%B9%A1%A6%A5%DE%A5%B0%A5%CA%20%28SSR%29 21736 SSR/コロッサス・マグナ 2024-05-02 {} {}
10 d27d0d9a-3b38-4dcd-89a5-4016c2906249 Bahamut バハムート 2040003000 3 5 0 TRUE TRUE 200 140 850 1210 1390 140 850 1210 1390 TRUE FALSE TRUE 2014-04-30 2017-03-10 2019-03-22 Bahamut %BE%A4%B4%AD%C0%D0%2F%A5%D0%A5%CF%A5%E0%A1%BC%A5%C8%20%28SSR%29 21612 SSR/バハムート {} {}

View file

@ -0,0 +1,42 @@
"id","name_en","name_jp","granblue_id","rarity","element","proficiency","series","flb","ulb","max_level","max_skill_level","min_hp","max_hp","max_hp_flb","max_hp_ulb","min_atk","max_atk","max_atk_flb","max_atk_ulb","extra","ax_type","limit","ax","nicknames_en","nicknames_jp","max_awakening_level","release_date","flb_date","ulb_date","wiki_en","wiki_ja","gamewith","kamigame","transcendence","transcendence_date","recruits"
"6c4f29c8-f43b-43f1-9fc5-967fb85c816e","Gauntlet of Proudearth","揺るがぬ大地の拳","1040611300",3,4,7,0,TRUE,FALSE,150,15,35,240,290,,390,2300,2780,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-04-24","2019-04-24",,"Gauntlet of Proudearth",,,,FALSE,,
"302ded88-b5c9-4570-b422-66fc40277c4f","Ixaba","イクサバ","1040906400",3,2,10,1,TRUE,FALSE,150,15,30,195,236,,502,3000,3620,,FALSE,0,FALSE,FALSE,"{}","{}",4,"2017-03-31","2018-05-21",,"Ixaba",,"72189","イクサバ",FALSE,,"3040115000"
"b540fbaf-48c9-41c0-981f-05953319b409","Skeletal Eclipse","呪蝕の骸槍","1040216900",3,5,4,1,TRUE,FALSE,150,15,43,280,339,,441,2547,3074,,FALSE,0,FALSE,FALSE,"{}","{}",,"2021-12-31","2021-12-31",,"Skeletal Eclipse","%C9%F0%B4%EF%2F%BC%F6%BF%AA%A4%CE%B3%BC%C1%E4%20%28SSR%29","314295","呪蝕の骸槍",FALSE,,"3040376000"
"aa6f8b9b-ed78-4b1a-8693-acefd5b455fc","Scythe of Repudiation","絶対否定の大鎌","1040310600",3,2,3,2,TRUE,TRUE,200,20,30,195,236,277,450,2730,3300,3870,FALSE,0,TRUE,FALSE,"{}","{}",,"2019-04-11","2019-04-11","2019-04-11","Scythe of Repudiation","{{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}}","{{{link_gamewith|146896}}}","{{{link_kamigame|{{{jpname|}}}}}}",TRUE,"2024-01-15",
"c6e4eeaa-bd19-466e-81ea-58310ed5cf25","Draconic Blade","ドラゴニックブレイド","1040912100",3,6,10,3,TRUE,TRUE,200,20,32,193,233,273,445,2744,3319,3894,FALSE,0,TRUE,FALSE,"{}","{}",,"2020-03-10","2020-03-10","2020-03-10","Draconic Blade","%C9%F0%B4%EF%2F%A5%C9%A5%E9%A5%B4%A5%CB%A5%C3%A5%AF%A5%D6%A5%EC%A5%A4%A5%C9%20%28SSR%29","190367",,FALSE,,
"1cedbb93-79ef-41ef-915f-94961ef9eba8","Nine-Realm Harp (Awakened)","九界琴・覚醒","1040801400",3,0,8,4,FALSE,FALSE,100,10,75,275,,,380,2470,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-03-10",,,"Nine-Realm Harp (Awakened)",,,,FALSE,,
"a5d72b41-6dea-4179-9996-36c01d2dad32","Winter's Frostnettle","冬ノ霜柱","1040111300",3,3,2,5,TRUE,FALSE,150,15,21,189,228,,290,1857,2249,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-07-12","2019-07-12",,"Winter%27s Frostnettle","%C9%F0%B4%EF%2F%C5%DF%A5%CE%C1%FA%C3%EC%20%28SSR%29","158278","冬ノ霜柱",FALSE,,
"620fbcd5-7c2e-4949-8cad-bbfb0908b00f","Ecke Sachs","エッケザックス","1040007100",3,2,1,6,TRUE,FALSE,150,15,106,664,800,,278,1677,2030,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2020-04-07",,"Ecke Sachs","%C9%F0%B4%EF%2F%A5%A8%A5%C3%A5%B1%A5%B6%A5%C3%A5%AF%A5%B9%20%28SSR%29","71702","エッケザックス",FALSE,,
"8cebe3c3-be12-4985-b45d-3e9db8204e6e","Ray of Zhuque Malus","朱雀光剣・邪","1040906700",3,2,10,7,TRUE,TRUE,200,20,22,145,175,205,345,2090,2530,2970,FALSE,0,TRUE,FALSE,"{}","{}",,"2017-04-10","2017-04-10","2022-04-07","Ray of Zhuque Malus",,"75564","朱雀光剣・邪",FALSE,,
"4380828f-1acc-46cd-b7eb-1cb8d34ca9ec","Last Storm Harp","ラストストームハープ","1040808300",3,1,8,8,TRUE,FALSE,150,15,62,223,260,,337,2059,2400,,FALSE,0,FALSE,FALSE,"{}","{}",,"2018-03-10","2018-03-10",,"Last Storm Harp",,,,FALSE,,
"ec3ba18a-9417-4ebe-a898-a74d5f15385f","Pillar of Flame","炎の柱","1040215200",3,6,4,8,TRUE,FALSE,150,15,37,213,250,,341,2250,2630,,FALSE,0,FALSE,FALSE,"{}","{}",,"2020-08-31","2020-08-31",,"Pillar of Flame","%C9%F0%B4%EF%2F%B1%EA%A4%CE%C3%EC%20%28SSR%29","225789","炎の柱",FALSE,,
"d61ee84f-4520-4064-8ff9-42a899273316","Luminiera Sword Omega","シュヴァリエソード・マグナ","1040007200",3,6,1,9,TRUE,TRUE,200,20,31,195,228,244,370,2275,2660,2850,FALSE,1,FALSE,TRUE,"{}","{}",,"2014-03-10",,"2018-03-10","Luminiera Sword Omega",,,,FALSE,,
"9f94d1e5-a117-432f-9da4-f3a5022b666d","Bow of Sigurd","シグルズの弓","1040705100",3,3,5,10,TRUE,FALSE,150,15,36,214,250,,365,2311,2701,,FALSE,3,FALSE,TRUE,"{}","{}",,"2014-03-10","2018-07-15",,"Bow of Sigurd",,,,FALSE,,
"82deb08e-8f92-44eb-8671-22426f89564e","Sword of Pallas Militis","パラスソード・ミーレス","1040022600",3,2,1,11,FALSE,FALSE,100,10,28,182,,,355,2153,,,TRUE,0,FALSE,FALSE,"{}","{}",,"2022-02-21",,,"Sword of Pallas Militis","%C9%F0%B4%EF%2F%A5%D1%A5%E9%A5%B9%A5%BD%A1%BC%A5%C9%A1%A6%A5%DF%A1%BC%A5%EC%A5%B9%20%28SSR%29","319816",,FALSE,,
"88492bc9-8085-4651-8a9d-305ab03d0710","Hollowsky Bow","虚空の歪弦","1040708900",3,6,5,12,TRUE,FALSE,150,15,37,234,280,,420,2580,3120,,FALSE,0,TRUE,FALSE,"{}","{}",,"2018-12-18","2018-12-18",,"Hollowsky Bow",,"134591","虚空の歪弦",FALSE,,
"9adb22c7-eb09-47e6-b100-783e0cefaf95","Last Sahrivar","ラスト・シャフレワル","1040015800",3,4,1,13,TRUE,TRUE,200,20,39,200,232,264,391,2240,2611,2982,FALSE,2,FALSE,TRUE,"{}","{}",,"2018-05-15","2018-05-15","2022-06-07","Last Sahrivar",,"105147",,FALSE,,
"6d4b41c6-2807-4aa6-9f69-14f5c2c68f37","Claws of Terror","黒銀の滅爪","1040612500",3,5,7,14,TRUE,TRUE,200,20,33,227,266,305,372,2196,2561,2926,FALSE,0,FALSE,FALSE,"{}","{}",,"2020-03-03","2020-03-03","2020-05-25","Claws of Terror",,"187437",,FALSE,,
"874eaf0b-5561-49d4-8983-ded494642a84","Rose Crystal Sword","ローズクリスタルソード","1040009700",3,3,1,15,FALSE,FALSE,100,10,34,204,,,365,2320,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2017-03-10",,,"Rose Crystal Sword",,,,FALSE,,
"e65ddc21-b8e9-45ee-8c0f-06013b4187a1","Spear of Bahamut Coda","バハムートスピア・フツルス","1040205400",3,5,4,16,TRUE,TRUE,200,20,37,248,290,332,380,2260,2640,3020,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10",,"2021-12-03","Spear of Bahamut Coda",,,,FALSE,,
"07dd062a-640c-4f00-9943-614b9f031271","Ultima Claw","オメガクロー","1040608100",3,0,7,17,TRUE,TRUE,200,20,35,277,313,349,393,2717,3066,3415,TRUE,0,TRUE,FALSE,"{}","{}",,"2014-03-10","2021-12-03","2021-12-03","Ultima Claw",,,,FALSE,,
"0c21542c-ce18-471e-ac80-1378fc97bec8","Scales of Dominion","支配の天秤","1040415800",3,5,6,18,TRUE,FALSE,150,15,38,261,320,,345,2020,2440,,FALSE,0,FALSE,FALSE,"{}","{}",,"2017-07-24","2019-08-06",,"Scales of Dominion",,"161169",,FALSE,,
"54c220d4-9cee-4f42-b184-5057cb1cb24a","Esna","エスナ","1040420600",3,3,6,19,TRUE,FALSE,150,15,50,294,355,,288,1859,2252,,FALSE,0,FALSE,FALSE,"{}","{}",15,"2022-07-20","2022-07-20",,"Esna","%C9%F0%B4%EF%2F%A5%A8%A5%B9%A5%CA%20%28SSR%29","352615",,FALSE,,
"742f29a3-2fa0-40f9-9275-126d892501b3","Cosmic Blade","ブレイド・オブ・コスモス","1040911800",3,6,10,20,TRUE,FALSE,150,15,31,184,222,,423,2610,3157,,FALSE,0,FALSE,FALSE,"{}","{}",,"2016-03-10","2019-12-19",,"Cosmic Blade",,,,FALSE,,
"c250d5c7-0208-49b5-9c88-fc51117dd7d3","Ewiyar's Beak","イーウィヤピーク","1040912400",3,1,10,21,TRUE,FALSE,150,15,31,192,224,,450,2749,3209,,FALSE,1,FALSE,TRUE,"{}","{}",,"2020-07-27","2020-07-27",,"Ewiyar%27s Beak",,"218570",,FALSE,,
"52d41363-16b1-42af-b185-ed1ba1308891","Ameno Habakiri","天羽々斬","1040904300",3,0,10,22,TRUE,TRUE,200,20,37,213,250,287,504,3024,3530,4036,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-03-10","2018-04-17","2020-05-11","Ameno Habakiri","%C9%F0%B4%EF%C9%BE%B2%C1%2F%A5%B9%A5%DA%A5%EA%A5%AA%A5%EB%A5%B7%A5%EA%A1%BC%A5%BA","75523","天羽々斬",FALSE,,
"a273dcbf-4d85-4898-89ac-41cc80c262d7","Gisla","グラーシーザー","1040200700",3,5,4,23,TRUE,FALSE,150,15,39,255,309,,404,2325,2810,,FALSE,0,FALSE,FALSE,"{}","{}",,"2014-05-14","2015-07-27",,"Gisla",,,,FALSE,,
"eeb5882d-63a1-4852-a753-32166b4b9b7f","Wasserspeier","ヴァッサーシュパイア","1040018100",3,0,1,24,TRUE,TRUE,200,20,32,205,240,275,390,2410,2814,3218,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-05-10","2019-05-10","2019-05-10","Wasserspeier","%C9%F0%B4%EF%2F%A5%F4%A5%A1%A5%C3%A5%B5%A1%BC%A5%B7%A5%E5%A5%D1%A5%A4%A5%A2%A1%BC%20%28SSR%29","150388","ヴァッサーシュパイアー",FALSE,,
"8137294e-6bf1-4bac-a1e0-38cdc542622b","Clarion","クラリオン","1040511200",3,3,9,25,TRUE,TRUE,200,20,27,163,200,237,385,2425,2940,3455,FALSE,0,FALSE,FALSE,"{}","{}",15,"2019-06-04","2019-06-04","2022-08-19","Clarion","%C9%F0%B4%EF%2F%A5%AF%A5%E9%A5%EA%A5%AA%A5%F3%20%28SSR%29","153291",,FALSE,,
"63a066c7-9f23-4c12-a921-ec56f584b0ed","Kaladanda","カラダンダ","1040416300",3,2,6,26,TRUE,FALSE,150,15,41,264,320,,333,2009,2428,,FALSE,0,FALSE,FALSE,"{}","{}",,"2019-12-19","2019-12-19",,"Kaladanda","%C9%F0%B4%EF%2F%A5%AB%A5%E9%A5%C0%A5%F3%A5%C0%20%28SSR%29","180277","カラダンダ",FALSE,,
"ac8da736-4041-45e2-b413-f859e6fae828","Magma Rush","マグマストリーム","1040408100",3,2,6,27,FALSE,FALSE,150,15,36,232,,,295,1770,,,FALSE,0,FALSE,FALSE,"{}","{}",,"2016-06-09","2023-07-12",,"Magma Rush",,"72005",,FALSE,,
"aec45e41-9874-465b-b668-9129a49d40c5","Sephira Emerald Duke","セフィラの翠甲","1040610000",3,4,7,28,TRUE,FALSE,150,15,38,232,271,,350,2175,2540,,TRUE,0,FALSE,FALSE,"{}","{}",,"2018-03-22","2020-12-04",,"Sephira Emerald Duke",,,,FALSE,,
"7f70e52a-d18c-4353-a135-1a841d3b7bf2","Rise of Justice","ライズ・オブ・ジャスティス","1040020800",3,3,1,29,TRUE,TRUE,200,20,34,202,244,286,367,2322,2811,3300,TRUE,0,FALSE,FALSE,"{}","{}",,"2020-12-04","2022-02-21","2022-12-26","Rise of Justice","%C9%F0%B4%EF%2F%A5%E9%A5%A4%A5%BA%A1%A6%A5%AA%A5%D6%A1%A6%A5%B8%A5%E3%A5%B9%A5%C6%A5%A3%A5%B9%20%28SSR%29","{{{link_gamewith|220273}}}",,FALSE,,
"e7a05d2e-a3ec-4620-98a5-d8472d474971","Fang of the Dragonslayer Mk II","竜伐の剛牙・再誕","1040117700",3,4,2,30,TRUE,FALSE,150,15,41,247,325,,394,2456,3230,,FALSE,,FALSE,FALSE,"{}","{}",20,"2023-11-09","2023-11-09",,"Fang of the Dragonslayer Mk II","","","",FALSE,,
"af83ceee-3a24-48c7-8cae-9f83276ced81","Hraesvelgr","フレズヴェルク","1040515200",3,3,9,31,TRUE,FALSE,150,15,37,211,246,,514,3131,3654,,FALSE,0,FALSE,FALSE,"{}","{}",,"2022-12-26","2022-12-26",,"Hraesvelgr","%C9%F0%B4%EF%2F%A5%D5%A5%EC%A5%BA%A5%F4%A5%A7%A5%EB%A5%AF%20%28SSR%29","366906",,FALSE,,
"47208685-e87a-4e07-b328-fb9ac3888718","Worldscathing Leon","レオン・オブ・ワールド","1040815100",3,2,8,32,TRUE,FALSE,150,15,44,281,350,,379,2286,2763,,TRUE,,FALSE,FALSE,"{}","{}",10,"2023-03-30","2023-12-19",,"Worldscathing Leon",,"393901",,FALSE,,
"a2f0db22-baf1-4640-8c2e-6d283375744f","Exo Antaeus","神銃エクス・アンタイオス","1040516300",3,3,9,33,TRUE,FALSE,150,15,29,169,204,,394,2488,3012,,FALSE,,FALSE,FALSE,"{}","{}",10,"2023-09-07","2023-09-07",,"Exo Antaeus","","","",FALSE,,
"b9522d2d-1d29-4a2b-b58c-d3b7c781feb6","Prayer of Grand Gales","狂飆と至高の祈り","1040422200",3,1,6,34,TRUE,TRUE,200,20,39,236,285,439,412,2566,3105,3302,TRUE,,TRUE,FALSE,"{draconic}","{ドラポン}",,"2023-10-23",,,"Prayer of Grand Gales","","","",FALSE,,
"81b9845a-a6d5-4aec-bbcf-1678277c1d79","Albacore Body","アルバコアボディ","1040423500",3,5,6,35,FALSE,FALSE,100,10,32,224,,,303,1759,,,FALSE,,FALSE,FALSE,"{}","{}",,"2024-07-29",,,"Albacore Body","アルバコアボディ (SSR)","458282","アルバコアボディ",FALSE,,
"f0d13eb4-f462-48d8-8705-16f91c351cb2","Syringe or Treat","シリンジ・オア・トリート","1040516400",3,5,9,36,FALSE,FALSE,100,10,19,130,,,504,2930,,,FALSE,,FALSE,FALSE,"{}","{}",,"2023-10-18",,,"Syringe or Treat","","","",FALSE,,"3040487000"
"3d9fad4c-a34f-4133-9d5e-c382a747eeec","Demolition-Tiger Axe","絶壊・威寅斧","1040319100",3,4,3,37,TRUE,FALSE,150,15,35,207,250,,456,2834,3429,,FALSE,,FALSE,FALSE,"{}","{}",,"2024-11-07","2024-11-07",,"Demolition-Tiger_Axe","絶壊・威寅斧 (SSR)","471097","絶壊・威寅斧",FALSE,,
"4110e59e-5b4c-40f8-ad83-2e62f5d60fc2","Yggdrasil Crystal Blade Arbos","世界樹の晶剣・アルボス","1040026300",3,4,1,38,TRUE,FALSE,150,15,32,200,242,,377,2332,2821,,FALSE,,FALSE,FALSE,"{}","{}",,"2024-06-03","2024-06-03",,"Yggdrasil Crystal Blade Arbos","","","",FALSE,,
1 id name_en name_jp granblue_id rarity element proficiency series flb ulb max_level max_skill_level min_hp max_hp max_hp_flb max_hp_ulb min_atk max_atk max_atk_flb max_atk_ulb extra ax_type limit ax nicknames_en nicknames_jp max_awakening_level release_date flb_date ulb_date wiki_en wiki_ja gamewith kamigame transcendence transcendence_date recruits
2 6c4f29c8-f43b-43f1-9fc5-967fb85c816e Gauntlet of Proudearth 揺るがぬ大地の拳 1040611300 3 4 7 0 TRUE FALSE 150 15 35 240 290 390 2300 2780 FALSE 0 FALSE FALSE {} {} 2019-04-24 2019-04-24 Gauntlet of Proudearth FALSE
3 302ded88-b5c9-4570-b422-66fc40277c4f Ixaba イクサバ 1040906400 3 2 10 1 TRUE FALSE 150 15 30 195 236 502 3000 3620 FALSE 0 FALSE FALSE {} {} 4 2017-03-31 2018-05-21 Ixaba 72189 イクサバ FALSE 3040115000
4 b540fbaf-48c9-41c0-981f-05953319b409 Skeletal Eclipse 呪蝕の骸槍 1040216900 3 5 4 1 TRUE FALSE 150 15 43 280 339 441 2547 3074 FALSE 0 FALSE FALSE {} {} 2021-12-31 2021-12-31 Skeletal Eclipse %C9%F0%B4%EF%2F%BC%F6%BF%AA%A4%CE%B3%BC%C1%E4%20%28SSR%29 314295 呪蝕の骸槍 FALSE 3040376000
5 aa6f8b9b-ed78-4b1a-8693-acefd5b455fc Scythe of Repudiation 絶対否定の大鎌 1040310600 3 2 3 2 TRUE TRUE 200 20 30 195 236 277 450 2730 3300 3870 FALSE 0 TRUE FALSE {} {} 2019-04-11 2019-04-11 2019-04-11 Scythe of Repudiation {{{link_jpwiki|%C9%F0%B4%EF%B3%B5%CD%D7%2F%BD%AA%CB%F6%A4%CE%BF%C0%B4%EF%A5%B7%A5%EA%A1%BC%A5%BA}}} {{{link_gamewith|146896}}} {{{link_kamigame|{{{jpname|}}}}}} TRUE 2024-01-15
6 c6e4eeaa-bd19-466e-81ea-58310ed5cf25 Draconic Blade ドラゴニックブレイド 1040912100 3 6 10 3 TRUE TRUE 200 20 32 193 233 273 445 2744 3319 3894 FALSE 0 TRUE FALSE {} {} 2020-03-10 2020-03-10 2020-03-10 Draconic Blade %C9%F0%B4%EF%2F%A5%C9%A5%E9%A5%B4%A5%CB%A5%C3%A5%AF%A5%D6%A5%EC%A5%A4%A5%C9%20%28SSR%29 190367 FALSE
7 1cedbb93-79ef-41ef-915f-94961ef9eba8 Nine-Realm Harp (Awakened) 九界琴・覚醒 1040801400 3 0 8 4 FALSE FALSE 100 10 75 275 380 2470 FALSE 0 FALSE FALSE {} {} 2014-03-10 Nine-Realm Harp (Awakened) FALSE
8 a5d72b41-6dea-4179-9996-36c01d2dad32 Winter's Frostnettle 冬ノ霜柱 1040111300 3 3 2 5 TRUE FALSE 150 15 21 189 228 290 1857 2249 FALSE 0 FALSE FALSE {} {} 2019-07-12 2019-07-12 Winter%27s Frostnettle %C9%F0%B4%EF%2F%C5%DF%A5%CE%C1%FA%C3%EC%20%28SSR%29 158278 冬ノ霜柱 FALSE
9 620fbcd5-7c2e-4949-8cad-bbfb0908b00f Ecke Sachs エッケザックス 1040007100 3 2 1 6 TRUE FALSE 150 15 106 664 800 278 1677 2030 FALSE 3 FALSE TRUE {} {} 2014-03-10 2020-04-07 Ecke Sachs %C9%F0%B4%EF%2F%A5%A8%A5%C3%A5%B1%A5%B6%A5%C3%A5%AF%A5%B9%20%28SSR%29 71702 エッケザックス FALSE
10 8cebe3c3-be12-4985-b45d-3e9db8204e6e Ray of Zhuque Malus 朱雀光剣・邪 1040906700 3 2 10 7 TRUE TRUE 200 20 22 145 175 205 345 2090 2530 2970 FALSE 0 TRUE FALSE {} {} 2017-04-10 2017-04-10 2022-04-07 Ray of Zhuque Malus 75564 朱雀光剣・邪 FALSE
11 4380828f-1acc-46cd-b7eb-1cb8d34ca9ec Last Storm Harp ラストストームハープ 1040808300 3 1 8 8 TRUE FALSE 150 15 62 223 260 337 2059 2400 FALSE 0 FALSE FALSE {} {} 2018-03-10 2018-03-10 Last Storm Harp FALSE
12 ec3ba18a-9417-4ebe-a898-a74d5f15385f Pillar of Flame 炎の柱 1040215200 3 6 4 8 TRUE FALSE 150 15 37 213 250 341 2250 2630 FALSE 0 FALSE FALSE {} {} 2020-08-31 2020-08-31 Pillar of Flame %C9%F0%B4%EF%2F%B1%EA%A4%CE%C3%EC%20%28SSR%29 225789 炎の柱 FALSE
13 d61ee84f-4520-4064-8ff9-42a899273316 Luminiera Sword Omega シュヴァリエソード・マグナ 1040007200 3 6 1 9 TRUE TRUE 200 20 31 195 228 244 370 2275 2660 2850 FALSE 1 FALSE TRUE {} {} 2014-03-10 2018-03-10 Luminiera Sword Omega FALSE
14 9f94d1e5-a117-432f-9da4-f3a5022b666d Bow of Sigurd シグルズの弓 1040705100 3 3 5 10 TRUE FALSE 150 15 36 214 250 365 2311 2701 FALSE 3 FALSE TRUE {} {} 2014-03-10 2018-07-15 Bow of Sigurd FALSE
15 82deb08e-8f92-44eb-8671-22426f89564e Sword of Pallas Militis パラスソード・ミーレス 1040022600 3 2 1 11 FALSE FALSE 100 10 28 182 355 2153 TRUE 0 FALSE FALSE {} {} 2022-02-21 Sword of Pallas Militis %C9%F0%B4%EF%2F%A5%D1%A5%E9%A5%B9%A5%BD%A1%BC%A5%C9%A1%A6%A5%DF%A1%BC%A5%EC%A5%B9%20%28SSR%29 319816 FALSE
16 88492bc9-8085-4651-8a9d-305ab03d0710 Hollowsky Bow 虚空の歪弦 1040708900 3 6 5 12 TRUE FALSE 150 15 37 234 280 420 2580 3120 FALSE 0 TRUE FALSE {} {} 2018-12-18 2018-12-18 Hollowsky Bow 134591 虚空の歪弦 FALSE
17 9adb22c7-eb09-47e6-b100-783e0cefaf95 Last Sahrivar ラスト・シャフレワル 1040015800 3 4 1 13 TRUE TRUE 200 20 39 200 232 264 391 2240 2611 2982 FALSE 2 FALSE TRUE {} {} 2018-05-15 2018-05-15 2022-06-07 Last Sahrivar 105147 FALSE
18 6d4b41c6-2807-4aa6-9f69-14f5c2c68f37 Claws of Terror 黒銀の滅爪 1040612500 3 5 7 14 TRUE TRUE 200 20 33 227 266 305 372 2196 2561 2926 FALSE 0 FALSE FALSE {} {} 2020-03-03 2020-03-03 2020-05-25 Claws of Terror 187437 FALSE
19 874eaf0b-5561-49d4-8983-ded494642a84 Rose Crystal Sword ローズクリスタルソード 1040009700 3 3 1 15 FALSE FALSE 100 10 34 204 365 2320 FALSE 0 FALSE FALSE {} {} 2017-03-10 Rose Crystal Sword FALSE
20 e65ddc21-b8e9-45ee-8c0f-06013b4187a1 Spear of Bahamut Coda バハムートスピア・フツルス 1040205400 3 5 4 16 TRUE TRUE 200 20 37 248 290 332 380 2260 2640 3020 TRUE 0 TRUE FALSE {} {} 2014-03-10 2021-12-03 Spear of Bahamut Coda FALSE
21 07dd062a-640c-4f00-9943-614b9f031271 Ultima Claw オメガクロー 1040608100 3 0 7 17 TRUE TRUE 200 20 35 277 313 349 393 2717 3066 3415 TRUE 0 TRUE FALSE {} {} 2014-03-10 2021-12-03 2021-12-03 Ultima Claw FALSE
22 0c21542c-ce18-471e-ac80-1378fc97bec8 Scales of Dominion 支配の天秤 1040415800 3 5 6 18 TRUE FALSE 150 15 38 261 320 345 2020 2440 FALSE 0 FALSE FALSE {} {} 2017-07-24 2019-08-06 Scales of Dominion 161169 FALSE
23 54c220d4-9cee-4f42-b184-5057cb1cb24a Esna エスナ 1040420600 3 3 6 19 TRUE FALSE 150 15 50 294 355 288 1859 2252 FALSE 0 FALSE FALSE {} {} 15 2022-07-20 2022-07-20 Esna %C9%F0%B4%EF%2F%A5%A8%A5%B9%A5%CA%20%28SSR%29 352615 FALSE
24 742f29a3-2fa0-40f9-9275-126d892501b3 Cosmic Blade ブレイド・オブ・コスモス 1040911800 3 6 10 20 TRUE FALSE 150 15 31 184 222 423 2610 3157 FALSE 0 FALSE FALSE {} {} 2016-03-10 2019-12-19 Cosmic Blade FALSE
25 c250d5c7-0208-49b5-9c88-fc51117dd7d3 Ewiyar's Beak イーウィヤピーク 1040912400 3 1 10 21 TRUE FALSE 150 15 31 192 224 450 2749 3209 FALSE 1 FALSE TRUE {} {} 2020-07-27 2020-07-27 Ewiyar%27s Beak 218570 FALSE
26 52d41363-16b1-42af-b185-ed1ba1308891 Ameno Habakiri 天羽々斬 1040904300 3 0 10 22 TRUE TRUE 200 20 37 213 250 287 504 3024 3530 4036 FALSE 0 FALSE FALSE {} {} 2014-03-10 2018-04-17 2020-05-11 Ameno Habakiri %C9%F0%B4%EF%C9%BE%B2%C1%2F%A5%B9%A5%DA%A5%EA%A5%AA%A5%EB%A5%B7%A5%EA%A1%BC%A5%BA 75523 天羽々斬 FALSE
27 a273dcbf-4d85-4898-89ac-41cc80c262d7 Gisla グラーシーザー 1040200700 3 5 4 23 TRUE FALSE 150 15 39 255 309 404 2325 2810 FALSE 0 FALSE FALSE {} {} 2014-05-14 2015-07-27 Gisla FALSE
28 eeb5882d-63a1-4852-a753-32166b4b9b7f Wasserspeier ヴァッサーシュパイア 1040018100 3 0 1 24 TRUE TRUE 200 20 32 205 240 275 390 2410 2814 3218 FALSE 0 FALSE FALSE {} {} 2019-05-10 2019-05-10 2019-05-10 Wasserspeier %C9%F0%B4%EF%2F%A5%F4%A5%A1%A5%C3%A5%B5%A1%BC%A5%B7%A5%E5%A5%D1%A5%A4%A5%A2%A1%BC%20%28SSR%29 150388 ヴァッサーシュパイアー FALSE
29 8137294e-6bf1-4bac-a1e0-38cdc542622b Clarion クラリオン 1040511200 3 3 9 25 TRUE TRUE 200 20 27 163 200 237 385 2425 2940 3455 FALSE 0 FALSE FALSE {} {} 15 2019-06-04 2019-06-04 2022-08-19 Clarion %C9%F0%B4%EF%2F%A5%AF%A5%E9%A5%EA%A5%AA%A5%F3%20%28SSR%29 153291 FALSE
30 63a066c7-9f23-4c12-a921-ec56f584b0ed Kaladanda カラダンダ 1040416300 3 2 6 26 TRUE FALSE 150 15 41 264 320 333 2009 2428 FALSE 0 FALSE FALSE {} {} 2019-12-19 2019-12-19 Kaladanda %C9%F0%B4%EF%2F%A5%AB%A5%E9%A5%C0%A5%F3%A5%C0%20%28SSR%29 180277 カラダンダ FALSE
31 ac8da736-4041-45e2-b413-f859e6fae828 Magma Rush マグマストリーム 1040408100 3 2 6 27 FALSE FALSE 150 15 36 232 295 1770 FALSE 0 FALSE FALSE {} {} 2016-06-09 2023-07-12 Magma Rush 72005 FALSE
32 aec45e41-9874-465b-b668-9129a49d40c5 Sephira Emerald Duke セフィラの翠甲 1040610000 3 4 7 28 TRUE FALSE 150 15 38 232 271 350 2175 2540 TRUE 0 FALSE FALSE {} {} 2018-03-22 2020-12-04 Sephira Emerald Duke FALSE
33 7f70e52a-d18c-4353-a135-1a841d3b7bf2 Rise of Justice ライズ・オブ・ジャスティス 1040020800 3 3 1 29 TRUE TRUE 200 20 34 202 244 286 367 2322 2811 3300 TRUE 0 FALSE FALSE {} {} 2020-12-04 2022-02-21 2022-12-26 Rise of Justice %C9%F0%B4%EF%2F%A5%E9%A5%A4%A5%BA%A1%A6%A5%AA%A5%D6%A1%A6%A5%B8%A5%E3%A5%B9%A5%C6%A5%A3%A5%B9%20%28SSR%29 {{{link_gamewith|220273}}} FALSE
34 e7a05d2e-a3ec-4620-98a5-d8472d474971 Fang of the Dragonslayer Mk II 竜伐の剛牙・再誕 1040117700 3 4 2 30 TRUE FALSE 150 15 41 247 325 394 2456 3230 FALSE FALSE FALSE {} {} 20 2023-11-09 2023-11-09 Fang of the Dragonslayer Mk II FALSE
35 af83ceee-3a24-48c7-8cae-9f83276ced81 Hraesvelgr フレズヴェルク 1040515200 3 3 9 31 TRUE FALSE 150 15 37 211 246 514 3131 3654 FALSE 0 FALSE FALSE {} {} 2022-12-26 2022-12-26 Hraesvelgr %C9%F0%B4%EF%2F%A5%D5%A5%EC%A5%BA%A5%F4%A5%A7%A5%EB%A5%AF%20%28SSR%29 366906 FALSE
36 47208685-e87a-4e07-b328-fb9ac3888718 Worldscathing Leon レオン・オブ・ワールド 1040815100 3 2 8 32 TRUE FALSE 150 15 44 281 350 379 2286 2763 TRUE FALSE FALSE {} {} 10 2023-03-30 2023-12-19 Worldscathing Leon 393901 FALSE
37 a2f0db22-baf1-4640-8c2e-6d283375744f Exo Antaeus 神銃エクス・アンタイオス 1040516300 3 3 9 33 TRUE FALSE 150 15 29 169 204 394 2488 3012 FALSE FALSE FALSE {} {} 10 2023-09-07 2023-09-07 Exo Antaeus FALSE
38 b9522d2d-1d29-4a2b-b58c-d3b7c781feb6 Prayer of Grand Gales 狂飆と至高の祈り 1040422200 3 1 6 34 TRUE TRUE 200 20 39 236 285 439 412 2566 3105 3302 TRUE TRUE FALSE {draconic} {ドラポン} 2023-10-23 Prayer of Grand Gales FALSE
39 81b9845a-a6d5-4aec-bbcf-1678277c1d79 Albacore Body アルバコアボディ 1040423500 3 5 6 35 FALSE FALSE 100 10 32 224 303 1759 FALSE FALSE FALSE {} {} 2024-07-29 Albacore Body アルバコアボディ (SSR) 458282 アルバコアボディ FALSE
40 f0d13eb4-f462-48d8-8705-16f91c351cb2 Syringe or Treat シリンジ・オア・トリート 1040516400 3 5 9 36 FALSE FALSE 100 10 19 130 504 2930 FALSE FALSE FALSE {} {} 2023-10-18 Syringe or Treat FALSE 3040487000
41 3d9fad4c-a34f-4133-9d5e-c382a747eeec Demolition-Tiger Axe 絶壊・威寅斧 1040319100 3 4 3 37 TRUE FALSE 150 15 35 207 250 456 2834 3429 FALSE FALSE FALSE {} {} 2024-11-07 2024-11-07 Demolition-Tiger_Axe 絶壊・威寅斧 (SSR) 471097 絶壊・威寅斧 FALSE
42 4110e59e-5b4c-40f8-ad83-2e62f5d60fc2 Yggdrasil Crystal Blade Arbos 世界樹の晶剣・アルボス 1040026300 3 4 1 38 TRUE FALSE 150 15 32 200 242 377 2332 2821 FALSE FALSE FALSE {} {} 2024-06-03 2024-06-03 Yggdrasil Crystal Blade Arbos FALSE

View file

@ -1,111 +0,0 @@
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

View file

@ -1,19 +0,0 @@
class AwsService
class ConfigurationError < StandardError
end
attr_reader bucket: String
attr_reader s3_client: Aws::S3::Client
def initialize: () -> void
def upload_stream: (IO io, String key) -> Aws::S3::Types::PutObjectOutput
def file_exists?: (String key) -> bool
private
def credentials: () -> Hash[Symbol, String]
def validate_credentials!: () -> void
end

View file

@ -1,53 +0,0 @@
module Granblue
module Downloaders
class BaseDownloader
SIZES: Array[String]
# Define allowed storage types
type storage = :local | :s3 | :both
@id: String
@base_url: String
@test_mode: bool
@verbose: bool
@storage: storage
@aws_service: AwsService
def initialize: (String id, ?test_mode: bool, ?verbose: bool, ?storage: storage) -> void
def download: -> void
private
def process_download: (String url, String size, String path, ?last: bool) -> void
def download_to_local: (String url, String download_uri) -> void
def stream_to_s3: (String url, String s3_key) -> void
def download_to_both: (String url, String download_uri, String s3_key) -> void
def should_download?: (String local_path, String s3_key) -> bool
def ensure_directories_exist: -> void
def store_locally?: -> bool
def download_path: (String size) -> String
def build_s3_key: (String size, String filename) -> String
def log_info: (String message) -> void
def download_elemental_image: (String url, String size, String path, String filename) -> void
def object_type: -> String
def base_url: -> String
def directory_for_size: (String size) -> String
def build_url: (String size) -> String
end
end
end

View file

@ -1,28 +0,0 @@
module Granblue
module Downloaders
class CharacterDownloader < BaseDownloader
private
def download_variants: (Character character) -> void
def download_variant: (String variant_id) -> void
def build_variant_url: (String variant_id, String size) -> String
def object_type: -> String
def base_url: -> String
def directory_for_size: (String size) -> String
private
@id: String
@base_url: String
@test_mode: bool
@verbose: bool
@storage: Symbol
@aws_service: AwsService
end
end
end

View file

@ -1,15 +0,0 @@
module Granblue
module Downloaders
class DownloadManager
def self.download_for_object: (
String type,
String granblue_id,
?test_mode: bool,
?verbose: bool,
?storage: Symbol
) -> void
private
end
end
end

View file

@ -1,30 +0,0 @@
module Granblue
module Downloaders
class SummonDownloader < BaseDownloader
def download: -> void
private
def download_variants: (Summon summon) -> void
def download_variant: (String variant_id) -> void
def build_variant_url: (String variant_id, String size) -> String
def object_type: -> String
def base_url: -> String
def directory_for_size: (String size) -> String
private
@id: String
@base_url: String
@test_mode: bool
@verbose: bool
@storage: Symbol
@aws_service: AwsService
end
end
end

View file

@ -1,48 +0,0 @@
module Granblue
module Downloaders
class WeaponDownloader < BaseDownloader
def download: -> void
private
def download_variants: (Weapon weapon) -> void
def download_variant: (String variant_id) -> void
def build_variant_url: (String variant_id, String size) -> String
def object_type: -> String
def base_url: -> String
def directory_for_size: (String size) -> String
def build_url_for_id: (String id, String size) -> String
# Track progress of elemental weapon downloads
def progress_reporter: (count: Integer, total: Integer, result: String, ?bar_len: Integer) -> void
private
@id: String
@base_url: String
@test_mode: bool
@verbose: bool
@storage: Symbol
@aws_service: AwsService
end
# Special downloader for handling elemental weapon variants
class ElementalWeaponDownloader < WeaponDownloader
SUFFIXES: Array[Integer]
def initialize: (Integer id_base) -> void
def download: -> void
private
@id_base: Integer
end
end
end

View file

@ -1,80 +0,0 @@
module Granblue
module Importers
class BaseImporter
attr_reader new_records: Hash[String, Array[Hash[Symbol, untyped]]]
attr_reader updated_records: Hash[String, Array[Hash[Symbol, untyped]]]
def initialize: (
String file_path,
?test_mode: bool,
?verbose: bool,
?logger: untyped
) -> void
def import: -> Hash[Symbol, Hash[String, Array[Hash[Symbol, untyped]]]]
def simulate_import: -> Hash[Symbol, Hash[String, Array[Hash[Symbol, untyped]]]]
private
def import_row: (CSV::Row row) -> void
def find_or_create_record: (Hash[Symbol, untyped] attributes) -> [untyped, bool]?
def simulate_create: (
Hash[Symbol, untyped] attributes,
Hash[String, Array[Hash[Symbol, untyped]]] simulated_new,
String type
) -> void
def simulate_update: (
untyped existing_record,
Hash[Symbol, untyped] attributes,
Hash[String, Array[Hash[Symbol, untyped]]] simulated_updated,
String type
) -> void
def validate_required_attributes: (Hash[Symbol, untyped] attributes) -> void
def validate_update_attributes: (Hash[Symbol, untyped] update_attributes) -> void
def validate_record: (untyped record) -> void
def track_record: ([untyped, bool] result) -> void
def format_attributes: (Hash[Symbol, untyped] attributes) -> String
def log_test_update: (untyped record, Hash[Symbol, untyped] attributes) -> void
def log_test_creation: (Hash[Symbol, untyped] attributes) -> void
def log_new_record: (untyped record) -> void
def log_updated_record: (untyped record) -> void
def parse_value: (String? value) -> String?
def parse_integer: (String? value) -> Integer?
def parse_float: (String? value) -> Float?
def parse_boolean: (String? value) -> bool?
def parse_date: (String? date_str) -> Date?
def parse_array: (String? array_str) -> Array[String]
def parse_integer_array: (String? array_str) -> Array[Integer]
def model_class: -> singleton(ActiveRecord::Base)
def build_attributes: (CSV::Row row) -> Hash[Symbol, untyped]
def handle_error: (StandardError error) -> void
def format_validation_error: (ActiveRecord::RecordInvalid error) -> String
def format_standard_error: (StandardError error) -> String
end
end
end

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
module Granblue
module Importers
class ImportError
attr_reader file_name: String
attr_reader details: String
def initialize: (file_name: String, details: String) -> void
private
def build_message: () -> String
end
def format_attributes: (
attributes: Hash[Symbol, String | Integer | Float | Boolean | Array[untyped] | nil]
) -> String
end
end

View file

@ -1,31 +0,0 @@
module Granblue
module Transformers
class TransformerError < StandardError
attr_reader details: untyped
def initialize: (String message, ?untyped details) -> void
end
class BaseTransformer
ELEMENT_MAPPING: Hash[Integer, Integer?]
@data: untyped
@options: Hash[Symbol, untyped]
@language: String
attr_reader data: untyped
attr_reader options: Hash[Symbol, untyped]
attr_reader language: String
def initialize: (untyped data, ?Hash[Symbol, untyped] options) -> void
def transform: -> untyped
def validate_data: -> bool
def get_master_param: (Hash[String, untyped] obj) -> [Hash[String, untyped]?, Hash[String, untyped]?]
def log_debug: (String message) -> void
end
end
end

View file

@ -1,17 +0,0 @@
module Granblue
module Transformers
class SummonTransformer < BaseTransformer
TRANSCENDENCE_LEVELS: Array[Integer]
@quick_summon_id: String?
def initialize: (untyped data, ?String? quick_summon_id, ?Hash[Symbol, untyped] options) -> void
def transform: -> Array[Hash[Symbol, untyped]]
private
def calculate_transcendence_level: (Integer? level) -> Integer
end
end
end

View file

@ -1,27 +0,0 @@
module Granblue
module Transformers
class WeaponTransformer < BaseTransformer
# Constants for level calculations
UNCAP_LEVELS: Array[Integer]
TRANSCENDENCE_LEVELS: Array[Integer]
MULTIELEMENT_SERIES: Array[Integer]
# Implements abstract method from BaseTransformer
def transform: -> Array[Hash[Symbol, untyped]]
private
def transform_base_attributes: (Hash[String, untyped] master, Hash[String, untyped] param) -> Hash[Symbol, untyped]
def transform_awakening: (Hash[String, untyped] param) -> Hash[Symbol, Hash[Symbol, untyped]]
def transform_ax_skills: (Hash[String, untyped] param) -> Hash[Symbol, Array[Hash[Symbol, untyped]]]
def transform_weapon_keys: (Hash[String, untyped] weapon_data) -> Hash[Symbol, Array[String]]
def calculate_uncap_level: (Integer? level) -> Integer
def calculate_transcendence_level: (Integer? level) -> Integer
end
end
end

View file

@ -1,23 +0,0 @@
module PreviewService
class Canvas
PREVIEW_WIDTH: Integer
PREVIEW_HEIGHT: Integer
DEFAULT_BACKGROUND_COLOR: String
def create_blank_canvas: (
?width: Integer,
?height: Integer,
?color: String
) -> Tempfile
def add_text: (
MiniMagick::Image image,
String text,
?x: Integer,
?y: Integer,
?size: String,
?color: String,
?font: String
) -> MiniMagick::Image
end
end

View file

@ -1,78 +0,0 @@
# sig/services/preview_service/coordinator.rbs
module PreviewService
class Coordinator
PREVIEW_FOLDER: String
PREVIEW_WIDTH: Integer
PREVIEW_HEIGHT: Integer
PREVIEW_EXPIRY: ActiveSupport::Duration
GENERATION_TIMEOUT: ActiveSupport::Duration
LOCAL_STORAGE_PATH: Pathname
@party: Party
@image_fetcher: ImageFetcherService
@grid_service: Grid
@canvas_service: Canvas
@aws_service: AwsService
def initialize: (party: Party) -> void
def get_s3_object: () -> Aws::S3::Types::GetObjectOutput
def preview_url: () -> String
def generate_preview: () -> bool
def force_regenerate: () -> bool
def delete_preview: () -> void
def should_generate?: () -> bool
def generation_in_progress?: () -> bool
def create_preview_image: () -> MiniMagick::Image
private
def setup_storage: () -> void
def add_job_icon: (image: MiniMagick::Image, job_icon: MiniMagick::Image) -> MiniMagick::Image
def organize_and_draw_weapons: (image: MiniMagick::Image, grid_layout: Hash[Symbol, untyped]) -> MiniMagick::Image
def draw_mainhand_weapon: (image: MiniMagick::Image, weapon_image: MiniMagick::Image) -> MiniMagick::Image
def save_preview: (image: MiniMagick::Image) -> void
def upload_to_s3: (image: MiniMagick::Image) -> void
def save_to_local_storage: (image: MiniMagick::Image) -> void
def preview_filename: () -> String
def local_preview_path: () -> Pathname
def local_preview_url: () -> String
def preview_key: () -> String
def preview_exists?: () -> bool
def generate_s3_url: () -> String
def set_generation_in_progress: () -> void
def clear_generation_in_progress: () -> void
def schedule_generation: () -> void
def default_preview_url: () -> String
def delete_s3_preview: () -> void
def delete_local_previews: () -> void
def handle_preview_generation_error: (error: Exception) -> void
end
end

View file

@ -1,18 +0,0 @@
module PreviewService
class Grid
GRID_MARGIN: Integer
GRID_CELL_SIZE: Integer
GRID_START_X: Integer
GRID_START_Y: Integer
def grid_position: (String type, Integer idx) -> { x: Integer, y: Integer }
def draw_grid_item: (
MiniMagick::Image image,
MiniMagick::Image item_image,
String type,
Integer idx,
?resize_to: Integer
) -> MiniMagick::Image
end
end

View file

@ -1,26 +0,0 @@
module PreviewService
class ImageFetcherService
@aws_service: AwsService
@tempfiles: Array[Tempfile]
def initialize: (AwsService aws_service) -> void
def fetch_s3_image: (String key, ?String folder) -> MiniMagick::Image?
def fetch_job_icon: (String job_name) -> MiniMagick::Image?
def fetch_weapon_image: (Weapon weapon, ?mainhand: bool) -> MiniMagick::Image?
def cleanup: -> void
private
def create_temp_file: -> Tempfile
def download_from_s3: (String key, Tempfile temp_file) -> void
def create_mini_magick_image: (Tempfile temp_file) -> MiniMagick::Image
def handle_fetch_error: (Exception error, String key) -> nil
end
end

View file

@ -0,0 +1,134 @@
# frozen_string_literal: true
require 'rails_helper'
# Dummy controller that includes the PartyAuthorizationConcern.
# This allows us to test its instance methods in isolation.
class DummyAuthorizationController < ActionController::Base
include PartyAuthorizationConcern
attr_accessor :party, :current_user, :edit_key
# Override render_unauthorized_response to set a flag.
def render_unauthorized_response
@_unauthorized_called = true
end
def unauthorized_called?
@_unauthorized_called || false
end
end
RSpec.describe DummyAuthorizationController, type: :controller do
let(:dummy_controller) { DummyAuthorizationController.new }
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:anonymous_party) { create(:party, user: nil, edit_key: 'anonkey') }
let(:owned_party) { create(:party, user: user) }
describe '#authorize_party!' do
context 'when the party belongs to a logged in user' do
before do
dummy_controller.party = owned_party
end
context 'and current_user matches party.user' do
before { dummy_controller.current_user = user }
it 'does not call render_unauthorized_response' do
dummy_controller.authorize_party!
expect(dummy_controller.unauthorized_called?).to be false
end
end
context 'and current_user is missing or does not match' do
before { dummy_controller.current_user = other_user }
it 'calls render_unauthorized_response' do
dummy_controller.authorize_party!
expect(dummy_controller.unauthorized_called?).to be true
end
end
end
context 'when the party is anonymous (no user)' do
before do
dummy_controller.party = anonymous_party
end
context 'with a valid edit_key' do
before { dummy_controller.edit_key = 'anonkey' }
it 'does not call render_unauthorized_response' do
dummy_controller.authorize_party!
expect(dummy_controller.unauthorized_called?).to be false
end
end
context 'with an invalid edit_key' do
before { dummy_controller.edit_key = 'wrongkey' }
it 'calls render_unauthorized_response' do
dummy_controller.authorize_party!
expect(dummy_controller.unauthorized_called?).to be true
end
end
end
end
describe '#not_owner?' do
context 'when the party belongs to a logged in user' do
before do
dummy_controller.party = owned_party
end
context 'and current_user matches party.user' do
before { dummy_controller.current_user = user }
it 'returns false' do
expect(dummy_controller.not_owner?).to be false
end
end
context 'and current_user does not match party.user' do
before { dummy_controller.current_user = other_user }
it 'returns true' do
expect(dummy_controller.not_owner?).to be true
end
end
end
context 'when the party is anonymous' do
before do
dummy_controller.party = anonymous_party
end
context 'and the provided edit_key matches' do
before { dummy_controller.edit_key = 'anonkey' }
it 'returns false' do
expect(dummy_controller.not_owner?).to be false
end
end
context 'and the provided edit_key does not match' do
before { dummy_controller.edit_key = 'wrongkey' }
it 'returns true' do
expect(dummy_controller.not_owner?).to be true
end
end
end
end
# Debug block: prints debug info if an example fails.
after(:each) do |example|
if example.exception && defined?(response) && response.present?
error_message = begin
JSON.parse(response.body)['exception']
rescue JSON::ParserError
response.body
end
puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}"
# Parse once and grab the trace safely
parsed_body = JSON.parse(response.body)
trace = parsed_body.dig('traces', 'Application Trace')
ap trace if trace # Only print if trace is not nil
end
end
end

View file

@ -0,0 +1,171 @@
# frozen_string_literal: true
require 'rails_helper'
# Dummy class including PartyQueryingConcern so that we can test its methods in isolation.
class DummyQueryClass
include PartyQueryingConcern
# Define a setter and getter for current_user so that the concern can call it.
attr_accessor :current_user
# Provide a basic params method for testing.
attr_writer :params
def params
@params ||= {}
end
end
RSpec.describe DummyQueryClass do
let(:dummy) { DummyQueryClass.new }
describe '#build_filters' do
context 'when parameters are provided' do
before do
dummy.params.merge!({
element: '3',
raid: 'raid_id_123',
recency: '3600',
full_auto: '1',
auto_guard: '0',
charge_attack: '1',
characters_count: '4',
summons_count: '3',
weapons_count: '6'
})
end
it 'builds a hash with converted values and a date range for created_at' do
filters = dummy.build_filters
expect(filters[:element]).to eq(3)
expect(filters[:raid_id]).to eq('raid_id_123')
expect(filters[:created_at]).to be_a(Range)
expect(filters[:full_auto]).to eq(1)
expect(filters[:auto_guard]).to eq(0)
expect(filters[:charge_attack]).to eq(1)
# For object count ranges, we expect a Range.
expect(filters[:characters_count]).to be_a(Range)
expect(filters[:summons_count]).to be_a(Range)
expect(filters[:weapons_count]).to be_a(Range)
end
end
context 'when no parameters are provided' do
before { dummy.params = {} }
it 'returns the default quality filters' do
filters = dummy.build_filters
expect(filters).to include(
characters_count: (PartyConstants::DEFAULT_MIN_CHARACTERS..PartyConstants::MAX_CHARACTERS),
summons_count: (PartyConstants::DEFAULT_MIN_SUMMONS..PartyConstants::MAX_SUMMONS),
weapons_count: (PartyConstants::DEFAULT_MIN_WEAPONS..PartyConstants::MAX_WEAPONS)
)
end
end
end
describe '#build_date_range' do
context 'with a recency parameter' do
before { dummy.params = { recency: '7200' } }
it 'returns a valid date range' do
date_range = dummy.build_date_range
expect(date_range).to be_a(Range)
expect(date_range.begin).to be <= DateTime.current
expect(date_range.end).to be >= DateTime.current - 2.hours
end
end
context 'without a recency parameter' do
before { dummy.params = {} }
it 'returns nil' do
expect(dummy.build_date_range).to be_nil
end
end
end
describe '#build_count' do
it 'returns the default value when blank' do
expect(dummy.build_count('', 3)).to eq(3)
end
it 'converts string values to integer' do
expect(dummy.build_count('5', 3)).to eq(5)
end
end
describe '#build_option' do
it 'returns nil for blank or -1 values' do
expect(dummy.build_option('')).to be_nil
expect(dummy.build_option('-1')).to be_nil
end
it 'returns the integer value for valid input' do
expect(dummy.build_option('2')).to eq(2)
end
end
describe '#grid_table_and_object_table' do
it 'maps id starting with "3" to grid_characters and characters' do
tables = dummy.grid_table_and_object_table('300000')
expect(tables).to eq(%w[grid_characters characters])
end
it 'maps id starting with "2" to grid_summons and summons' do
tables = dummy.grid_table_and_object_table('200000')
expect(tables).to eq(%w[grid_summons summons])
end
it 'maps id starting with "1" to grid_weapons and weapons' do
tables = dummy.grid_table_and_object_table('100000')
expect(tables).to eq(%w[grid_weapons weapons])
end
it 'returns [nil, nil] for an unknown prefix' do
tables = dummy.grid_table_and_object_table('900000')
expect(tables).to eq([nil, nil])
end
end
describe '#remixed_name' do
context 'when current_user is present' do
let(:user) { build(:user, language: 'en') }
before { dummy.instance_variable_set(:@current_user, user) }
it 'returns a remix name in English' do
expect(dummy.remixed_name('Original Party')).to eq('Remix of Original Party')
end
context 'when user language is Japanese' do
let(:user) { build(:user, language: 'ja') }
before { dummy.instance_variable_set(:@current_user, user) }
it 'returns a remix name in Japanese' do
expect(dummy.remixed_name('オリジナル')).to eq('オリジナルのリミックス')
end
end
end
context 'when current_user is nil' do
before { dummy.instance_variable_set(:@current_user, nil) }
it 'returns a remix name in English by default' do
expect(dummy.remixed_name('Original Party')).to eq('Remix of Original Party')
end
end
end
# Debug block: prints debugging information if an example fails.
after(:each) do |example|
if example.exception && defined?(response) && response.present?
error_message = begin
JSON.parse(response.body)['exception']
rescue JSON::ParserError
response.body
end
puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}"
# Parse once and grab the trace safely
parsed_body = JSON.parse(response.body)
trace = parsed_body.dig('traces', 'Application Trace')
ap trace if trace # Only print if trace is not nil
end
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
#
# Factory for the Favorite model. This factory sets up the associations to User and Party,
# which are required as per the model definition.
#
FactoryBot.define do
factory :favorite do
association :user
association :party
end
end

View file

@ -0,0 +1,20 @@
FactoryBot.define do
factory :grid_character do
association :party
# Use the canonical (seeded) Character record.
# Make sure your CSV canonical data (loaded via canonical.rb) includes a Character with the specified granblue_id.
character { Character.find_by!(granblue_id: '3040087000') }
position { 0 }
uncap_level { 3 }
transcendence_step { 0 }
# Virtual attributes default to nil.
new_rings { nil }
new_awakening { nil }
# JSON columns for ring data are set to default hashes.
ring1 { { 'modifier' => nil, 'strength' => nil } }
ring2 { { 'modifier' => nil, 'strength' => nil } }
ring3 { { 'modifier' => nil, 'strength' => nil } }
ring4 { { 'modifier' => nil, 'strength' => nil } }
earring { { 'modifier' => nil, 'strength' => nil } }
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
FactoryBot.define do
factory :grid_summon do
association :party
# Use the canonical (seeded) Summon record.
# Make sure your CSV canonical data (loaded via canonical.rb) includes a Summon with the specified granblue_id.
summon { Summon.find_by!(granblue_id: '2040433000') }
position { 1 }
uncap_level { 3 }
transcendence_step { 0 }
main { false }
friend { false }
quick_summon { false }
end
end

View file

@ -1,5 +1,17 @@
# frozen_string_literal: true
FactoryBot.define do
factory :grid_weapon do
# Associations: assumes that factories for :party and :weapon are defined.
association :party
association :weapon
# Default attributes
position { 0 }
uncap_level { 3 }
transcendence_step { 0 }
mainhand { false }
# Optional associations for weapon keys and awakening are left as nil by default.
end
end

View file

@ -1,5 +1,21 @@
# frozen_string_literal: true
FactoryBot.define do
factory :party do
association :user
# Use a sequence for unique party names (optional).
sequence(:name) { |n| "Party #{n}" }
description { Faker::Lorem.sentence }
extra { false }
full_auto { false }
auto_guard { false }
charge_attack { true }
clear_time { 0 }
button_count { 0 }
chain_count { 0 }
turn_count { 0 }
visibility { 1 }
# Note: Shortcode and edit_key will be auto-generated via before_create callbacks.
end
end

View file

@ -1,5 +1,16 @@
FactoryBot.define do
factory :user do
email { Faker::Internet.email }
password { "password" }
password_confirmation { "password" }
username { Faker::Internet.username(specifier: 5..8) }
granblue_id { Faker::Number.number(digits: 4) }
picture { "gran" }
language { ["en", "ja"].sample }
private { Faker::Boolean.boolean }
element { ["water", "fire", "wind", "earth", "light", "dark"].sample }
gender { Faker::Number.between(from: 0, to: 1) }
theme { ["system", "dark", "light"].sample }
role { Faker::Number.between(from: 1, to: 3) }
end
end

View file

@ -0,0 +1,166 @@
# frozen_string_literal: true
# spec/models/grid_character_spec.rb
#
# This spec verifies the GridCharacter models associations, validations,
# and callbacks. It uses FactoryBot for object creation, shoulda-matchers
# for association/validation shortcuts, and a custom matcher (have_error_on)
# for checking that error messages include specific phrases.
#
# In this version we use canonical data loaded from CSV (via our CSV loader)
# rather than generating new Character and Awakening records.
#
require 'rails_helper'
RSpec.describe GridCharacter, type: :model do
# Association tests using shoulda-matchers.
it { is_expected.to belong_to(:character) }
it { is_expected.to belong_to(:party) }
it { is_expected.to belong_to(:awakening).optional }
# Use the canonical "Balanced" awakening already loaded from CSV.
before(:all) do
@balanced_awakening = Awakening.find_by!(slug: 'character-balanced')
end
# Use canonical records loaded from CSV for our character.
let(:party) { create(:party) }
let(:character) do
# Assume canonical test data has been loaded.
Character.find_by!(granblue_id: '3040087000')
end
let(:valid_attributes) do
{
party: party,
character: character,
position: 0,
uncap_level: 3,
transcendence_step: 0
}
end
describe 'Validations and Associations' do
context 'with valid attributes' do
subject { build(:grid_character, valid_attributes) }
it 'is valid' do
expect(subject).to be_valid
end
end
context 'without a party' do
subject { build(:grid_character, valid_attributes.merge(party: nil)) }
it 'is invalid' do
subject.valid?
expect(subject.errors[:party]).to include("can't be blank")
end
end
end
describe 'Callbacks' do
context 'before_validation :apply_new_rings' do
it 'sets the ring attributes when new_rings is provided' do
grid_char = build(
:grid_character,
valid_attributes.merge(new_rings: [
{ 'modifier' => '1', 'strength' => 300 },
{ 'modifier' => '2', 'strength' => 150 }
])
)
grid_char.valid? # triggers the before_validation callback
expect(grid_char.ring1).to eq({ 'modifier' => '1', 'strength' => 300 })
expect(grid_char.ring2).to eq({ 'modifier' => '2', 'strength' => 150 })
# The rings array is padded to have exactly four entries.
expect(grid_char.ring3).to eq({ 'modifier' => nil, 'strength' => nil })
expect(grid_char.ring4).to eq({ 'modifier' => nil, 'strength' => nil })
end
end
context 'before_validation :apply_new_awakening' do
it 'sets awakening_id and awakening_level when new_awakening is provided using a canonical awakening' do
# Use an existing awakening from the CSV data.
canonical_awakening = Awakening.find_by!(slug: 'character-def')
new_awakening = { id: canonical_awakening.id, level: '5' }
grid_char = build(:grid_character, valid_attributes.merge(new_awakening: new_awakening))
grid_char.valid?
expect(grid_char.awakening_id).to eq(canonical_awakening.id)
expect(grid_char.awakening_level).to eq(5)
end
end
context 'before_save :add_awakening' do
it 'sets the awakening to the balanced canonical awakening if none is provided' do
grid_char = build(:grid_character, valid_attributes.merge(awakening: nil))
grid_char.save!
expect(grid_char.awakening).to eq(@balanced_awakening)
end
it 'does not override an existing awakening' do
existing_awakening = Awakening.find_by!(slug: 'character-def')
grid_char = build(:grid_character, valid_attributes.merge(awakening: existing_awakening))
grid_char.save!
expect(grid_char.awakening).to eq(existing_awakening)
end
end
end
describe 'Update Validations (on :update)' do
before do
# Persist a valid GridCharacter record.
@grid_char = create(:grid_character, valid_attributes)
end
context 'validate_awakening_level' do
it 'adds an error if awakening_level is below 1' do
@grid_char.awakening_level = 0
@grid_char.valid?(:update)
expect(@grid_char.errors[:awakening]).to include('awakening level too low')
end
it 'adds an error if awakening_level is above 9' do
@grid_char.awakening_level = 10
@grid_char.valid?(:update)
expect(@grid_char.errors[:awakening]).to include('awakening level too high')
end
end
context 'transcendence validation' do
it 'adds an error if transcendence_step is positive but character.ulb is false' do
@grid_char.character.update!(ulb: false)
@grid_char.transcendence_step = 1
@grid_char.valid?(:update)
expect(@grid_char.errors[:transcendence_step]).to include('character has no transcendence')
end
it 'adds an error if transcendence_step is greater than 5 when character.ulb is true' do
@grid_char.character.update!(ulb: true)
@grid_char.transcendence_step = 6
@grid_char.valid?(:update)
expect(@grid_char.errors[:transcendence_step]).to include('transcendence step too high')
end
it 'adds an error if transcendence_step is negative when character.ulb is true' do
@grid_char.character.update!(ulb: true)
@grid_char.transcendence_step = -1
@grid_char.valid?(:update)
expect(@grid_char.errors[:transcendence_step]).to include('transcendence step too low')
end
end
context 'over_mastery_attack_matches_hp validation' do
it 'adds an error if ring1 and ring2 values are inconsistent' do
@grid_char.ring1 = { modifier: '1', strength: 300 }
# Expected: ring2 strength should be half of 300 (i.e. 150)
@grid_char.ring2 = { modifier: '2', strength: 100 }
@grid_char.valid?(:update)
expect(@grid_char.errors[:over_mastery]).to include('over mastery attack and hp values do not match')
end
it 'is valid if ring2 strength equals half of ring1 strength' do
@grid_char.ring1 = { modifier: '1', strength: 300 }
@grid_char.ring2 = { modifier: '2', strength: 150 }
@grid_char.valid?(:update)
expect(@grid_char.errors[:over_mastery]).to be_empty
end
end
end
end

View file

@ -0,0 +1,235 @@
# frozen_string_literal: true
require 'rails_helper'
# Define a dummy GridSummonBlueprint if it is not already defined.
class GridSummonBlueprint; end unless defined?(GridSummonBlueprint)
RSpec.describe GridSummon, type: :model do
describe 'associations' do
it 'belongs to a party' do
association = described_class.reflect_on_association(:party)
expect(association).not_to be_nil
expect(association.macro).to eq(:belongs_to)
end
it 'belongs to a summon' do
association = described_class.reflect_on_association(:summon)
expect(association).not_to be_nil
expect(association.macro).to eq(:belongs_to)
end
end
describe 'validations' do
let(:party) { create(:party) }
let(:default_summon) { Summon.find_by!(granblue_id: '2040433000') }
context 'with valid attributes' do
subject do
build(:grid_summon,
party: party,
summon: default_summon,
position: 1,
uncap_level: 3,
transcendence_step: 0,
main: false,
friend: false,
quick_summon: false)
end
it 'is valid' do
expect(subject).to be_valid
end
end
context 'with missing required attributes' do
it 'is invalid without a position' do
grid_summon = build(:grid_summon,
party: party,
summon: default_summon,
position: nil,
uncap_level: 3,
transcendence_step: 0)
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:position].join).to match(/can't be blank/)
end
it 'is invalid without a party' do
grid_summon = build(:grid_summon,
party: nil,
summon: default_summon,
position: 1,
uncap_level: 3,
transcendence_step: 0)
grid_summon.validate
expect(grid_summon.errors[:party].join).to match(/must exist|can't be blank/)
end
it 'is invalid without a summon' do
grid_summon = build(:grid_summon,
party: party,
summon: nil,
position: 1,
uncap_level: 3,
transcendence_step: 0)
expect { grid_summon.valid? }.to raise_error(NoMethodError)
end
end
context 'with non-numeric values' do
it 'is invalid when uncap_level is non-numeric' do
grid_summon = build(:grid_summon,
party: party,
summon: default_summon,
position: 1,
uncap_level: 'three',
transcendence_step: 0)
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:uncap_level]).not_to be_empty
end
it 'is invalid when transcendence_step is non-numeric' do
grid_summon = build(:grid_summon,
party: party,
summon: default_summon,
position: 1,
uncap_level: 3,
transcendence_step: 'one')
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:transcendence_step]).not_to be_empty
end
end
context 'custom validations based on Summon flags' do
context 'when the summon does not have FLB flag' do
let(:summon_without_flb) { default_summon.tap { |s| s.flb = false } }
it 'is invalid if uncap_level is greater than 3' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_flb,
position: 1,
uncap_level: 4,
transcendence_step: 0)
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:uncap_level].join).to match(/cannot be greater than 3/)
end
it 'is valid if uncap_level is 3 or less' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_flb,
position: 1,
uncap_level: 3,
transcendence_step: 0)
expect(grid_summon).to be_valid
end
end
context 'when the summon does not have ULB flag' do
let(:summon_without_ulb) do
default_summon.tap do |s|
s.ulb = false
s.flb = true
end
end
it 'is invalid if uncap_level is greater than 4' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_ulb,
position: 1,
uncap_level: 5,
transcendence_step: 0)
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:uncap_level].join).to match(/cannot be greater than 4/)
end
it 'is valid if uncap_level is 4 or less' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_ulb,
position: 1,
uncap_level: 4,
transcendence_step: 0)
expect(grid_summon).to be_valid
end
end
context 'when the summon does not have transcendence flag' do
let(:summon_without_transcendence) do
# Ensure FLB and ULB are true so that only the transcendence rule applies.
default_summon.tap do |s|
s.transcendence = false
s.flb = true
s.ulb = true
end
end
it 'is invalid if uncap_level is greater than 5' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_transcendence,
position: 1,
uncap_level: 6,
transcendence_step: 0)
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:uncap_level].join).to match(/cannot be greater than 5/)
end
it 'is invalid if transcendence_step is greater than 0' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_transcendence,
position: 1,
uncap_level: 5,
transcendence_step: 1)
expect(grid_summon).not_to be_valid
expect(grid_summon.errors[:transcendence_step].join).to match(/must be 0/)
end
it 'is valid if uncap_level is 5 or less and transcendence_step is 0' do
grid_summon = build(:grid_summon,
party: party,
summon: summon_without_transcendence,
position: 1,
uncap_level: 5,
transcendence_step: 0)
expect(grid_summon).to be_valid
end
end
end
end
describe 'default values' do
let(:party) { create(:party) }
let(:summon) { Summon.find_by!(granblue_id: '2040433000') }
subject do
build(:grid_summon,
party: party,
summon: summon,
position: 1,
uncap_level: 3,
transcendence_step: 0)
end
it 'defaults quick_summon to false' do
expect(subject.quick_summon).to be_falsey
end
it 'defaults main to false' do
expect(subject.main).to be_falsey
end
it 'defaults friend to false' do
expect(subject.friend).to be_falsey
end
end
describe '#blueprint' do
it 'returns the GridSummonBlueprint constant' do
grid_summon = build(:grid_summon)
expect(grid_summon.blueprint).to eq(GridSummonBlueprint)
end
end
end

View file

@ -1,5 +1,131 @@
# frozen_string_literal: true
require 'rails_helper'
# Define a dummy GridWeaponBlueprint if it is not already defined.
class GridWeaponBlueprint; end unless defined?(GridWeaponBlueprint)
RSpec.describe GridWeapon, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
it { is_expected.to belong_to(:weapon) }
it { is_expected.to belong_to(:party) }
it { is_expected.to belong_to(:weapon_key1).optional }
it { is_expected.to belong_to(:weapon_key2).optional }
it { is_expected.to belong_to(:weapon_key3).optional }
it { is_expected.to belong_to(:weapon_key4).optional }
it { is_expected.to belong_to(:awakening).optional }
# Setup common test objects using FactoryBot.
let(:party) { create(:party) }
let(:weapon) { create(:weapon, limit: false, series: 5) } # a non-limited weapon with series 5
let(:grid_weapon) do
build(:grid_weapon,
party: party,
weapon: weapon,
position: 0,
uncap_level: 3,
transcendence_step: 0)
end
describe 'Validations' do
context 'Presence validations' do
it 'requires a party' do
grid_weapon.party = nil
grid_weapon.validate
error_message = grid_weapon.errors[:party].join
expect(error_message).to include('must exist')
end
end
context 'Custom validations' do
describe '#compatible_with_position' do
context 'when position is within extra positions [9, 10, 11]' do
before { grid_weapon.position = 9 }
context 'and weapon series is NOT in allowed extra series' do
before { weapon.series = 5 } # Allowed extra series are [11, 16, 17, 28, 29, 32, 34]
it 'adds an error on :series' do
grid_weapon.validate
expect(grid_weapon.errors[:series]).to include('must be compatible with position')
end
end
context 'and weapon series is in allowed extra series' do
before { weapon.series = 11 }
it 'is valid with respect to position compatibility' do
grid_weapon.validate
expect(grid_weapon.errors[:series]).to be_empty
end
end
end
context 'when position is not in extra positions' do
before { grid_weapon.position = 2 }
it 'does not add an error on :series' do
grid_weapon.validate
expect(grid_weapon.errors[:series]).to be_empty
end
end
end
describe '#no_conflicts' do
context 'when there is a conflicting grid weapon in the party' do
before do
# Create a limited weapon that will trigger conflict checking.
limited_weapon = create(:weapon, limit: true, series: 7)
# Create an existing grid weapon in the party using that limited weapon.
create(:grid_weapon, party: party, weapon: limited_weapon, position: 1)
# Set up grid_weapon to use the same limited weapon in a different position.
grid_weapon.weapon = limited_weapon
grid_weapon.position = 2
end
it 'adds an error on :series about conflicts' do
grid_weapon.validate
expect(grid_weapon.errors[:series]).to include('must not conflict with existing weapons')
end
end
context 'when there is no conflicting grid weapon' do
it 'has no conflict errors' do
grid_weapon.validate
expect(grid_weapon.errors[:series]).to be_empty
end
end
end
end
end
describe 'Callbacks' do
context 'before_save :mainhand?' do
it 'sets mainhand to true if position is -1' do
grid_weapon.position = -1
grid_weapon.save!
expect(grid_weapon.mainhand).to be true
end
it 'sets mainhand to false if position is not -1' do
grid_weapon.position = 0
grid_weapon.save!
expect(grid_weapon.mainhand).to be false
end
end
end
describe '#weapon_keys' do
it 'returns an array of associated weapon keys, omitting nils' do
# Create two dummy weapon keys using the factory.
weapon_key1 = create(:weapon_key)
weapon_key2 = create(:weapon_key)
grid_weapon.weapon_key1 = weapon_key1
grid_weapon.weapon_key2 = weapon_key2
grid_weapon.weapon_key3 = nil
expect(grid_weapon.weapon_keys).to match_array([weapon_key1, weapon_key2])
end
end
describe '#blueprint' do
it 'returns the GridWeaponBlueprint constant' do
expect(grid_weapon.blueprint).to eq(GridWeaponBlueprint)
end
end
end

View file

@ -1,5 +1,416 @@
require 'rails_helper'
include ActiveJob::TestHelper
RSpec.describe Party, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
describe 'validations' do
context 'for element' do
it 'is valid when element is nil' do
party = build(:party, element: nil)
expect(party).to be_valid
end
it 'is valid when element is one of the allowed values' do
GranblueEnums::ELEMENTS.values.each do |value|
party = build(:party, element: value)
expect(party).to be_valid, "expected element #{value} to be valid"
end
end
it 'is invalid when element is not one of the allowed values' do
party = build(:party, element: 7)
expect(party).not_to be_valid
expect(party.errors[:element]).to include(/must be one of/)
end
it 'is invalid when element is not an integer' do
party = build(:party, element: 'fire')
expect(party).not_to be_valid
expect(party.errors[:element]).to include(/is not a number/)
end
end
context 'for master_level' do
it { should validate_numericality_of(:master_level).only_integer.allow_nil }
it 'is invalid when master_level is non-integer' do
party = build(:party, master_level: 'high')
expect(party).not_to be_valid
expect(party.errors[:master_level]).to include(/is not a number/)
end
end
context 'for clear_time' do
it { should validate_numericality_of(:clear_time).only_integer }
it 'is invalid when clear_time is non-integer' do
party = build(:party, clear_time: 'fast')
expect(party).not_to be_valid
expect(party.errors[:clear_time]).to include(/is not a number/)
end
end
context 'for button_count' do
it { should validate_numericality_of(:button_count).only_integer.allow_nil }
it 'is invalid when button_count is non-integer' do
party = build(:party, button_count: 'ten')
expect(party).not_to be_valid
expect(party.errors[:button_count]).to include(/is not a number/)
end
end
context 'for chain_count' do
it { should validate_numericality_of(:chain_count).only_integer.allow_nil }
it 'is invalid when chain_count is non-integer' do
party = build(:party, chain_count: 'two')
expect(party).not_to be_valid
expect(party.errors[:chain_count]).to include(/is not a number/)
end
end
context 'for turn_count' do
it { should validate_numericality_of(:turn_count).only_integer.allow_nil }
it 'is invalid when turn_count is non-integer' do
party = build(:party, turn_count: 'five')
expect(party).not_to be_valid
expect(party.errors[:turn_count]).to include(/is not a number/)
end
end
context 'for ultimate_mastery' do
it { should validate_numericality_of(:ultimate_mastery).only_integer.allow_nil }
it 'is invalid when ultimate_mastery is non-integer' do
party = build(:party, ultimate_mastery: 'max')
expect(party).not_to be_valid
expect(party.errors[:ultimate_mastery]).to include(/is not a number/)
end
end
context 'for visibility' do
it { should validate_numericality_of(:visibility).only_integer }
it 'is valid when visibility is one of 1, 2, or 3' do
[1, 2, 3].each do |value|
party = build(:party, visibility: value)
expect(party).to be_valid, "expected visibility #{value} to be valid"
end
end
it 'is invalid when visibility is not in [1, 2, 3]' do
party = build(:party, visibility: 0)
expect(party).not_to be_valid
expect(party.errors[:visibility]).to include(/must be 1 \(Public\), 2 \(Unlisted\), or 3 \(Private\)/)
end
it 'is invalid when visibility is non-integer' do
party = build(:party, visibility: 'public')
expect(party).not_to be_valid
expect(party.errors[:visibility]).to include(/is not a number/)
end
end
context 'for preview_state' do
# Since preview_state is now an enum, we test valid enum keys.
it 'allows valid preview_state values via enum' do
%w[pending queued in_progress generated failed].each do |state|
party = build(:party, preview_state: state)
expect(party).to be_valid, "expected preview_state #{state} to be valid"
end
end
it 'is invalid when preview_state is non-numeric and not a valid enum key' do
expect { build(:party, preview_state: 'active') }
.to raise_error(ArgumentError, /'active' is not a valid preview_state/)
end
end
end
describe '#is_remix' do
context 'when source_party is nil' do
it 'returns false' do
party = build(:party, source_party: nil)
expect(party.remix?).to be false
end
end
context 'when source_party is present' do
it 'returns true' do
parent = create(:party)
remix = build(:party, source_party: parent)
expect(remix.remix?).to be true
end
end
end
describe '#remixes' do
it 'returns all parties whose source_party_id equals the party id' do
parent = create(:party)
remix1 = create(:party, source_party: parent)
remix2 = create(:party, source_party: parent)
expect(parent.remixes.map(&:id)).to match_array([remix1.id, remix2.id])
end
end
describe 'Visibility helpers (#public?, #unlisted?, #private?)' do
it 'returns public? true when visibility is 1' do
party = build(:party, visibility: 1)
expect(party.public?).to be true
expect(party.unlisted?).to be false
expect(party.private?).to be false
end
it 'returns unlisted? true when visibility is 2' do
party = build(:party, visibility: 2)
expect(party.unlisted?).to be true
expect(party.public?).to be false
expect(party.private?).to be false
end
it 'returns private? true when visibility is 3' do
party = build(:party, visibility: 3)
expect(party.private?).to be true
expect(party.public?).to be false
expect(party.unlisted?).to be false
end
end
describe '#is_favorited' do
let(:user) { create(:user) }
it 'returns false if the passed user is nil' do
party = build(:party)
expect(party.favorited?(nil)).to be false
end
it 'returns true if the party is favorited by the user' do
party = create(:party)
create(:favorite, user: user, party: party)
Rails.cache.clear
expect(party.favorited?(user)).to be true
end
it 'returns false if the party is not favorited by the user' do
party = create(:party)
Rails.cache.clear
expect(party.favorited?(user)).to be false
end
end
describe '#ready_for_preview?' do
it 'returns false if weapons_count is less than 1' do
party = build(:party, weapons_count: 0, characters_count: 1, summons_count: 1)
expect(party.ready_for_preview?).to be false
end
it 'returns false if characters_count is less than 1' do
party = build(:party, weapons_count: 1, characters_count: 0, summons_count: 1)
expect(party.ready_for_preview?).to be false
end
it 'returns false if summons_count is less than 1' do
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 0)
expect(party.ready_for_preview?).to be false
end
it 'returns true when all counts are at least 1' do
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1)
expect(party.ready_for_preview?).to be true
end
end
describe '#preview_expired?' do
it 'returns true if preview_generated_at is nil' do
party = build(:party, preview_generated_at: nil)
expect(party.preview_expired?).to be true
end
it 'returns true if preview_generated_at is older than the expiry period' do
expired_time = PreviewService::Coordinator::PREVIEW_EXPIRY.ago - 1.minute
party = build(:party, preview_generated_at: expired_time)
expect(party.preview_expired?).to be true
end
it 'returns false if preview_generated_at is recent' do
recent_time = Time.current - 1.hour
party = build(:party, preview_generated_at: recent_time)
expect(party.preview_expired?).to be false
end
end
describe '#preview_content_changed?' do
it 'returns true if saved_changes include a preview relevant attribute' do
party = build(:party)
# Stub saved_changes so that it includes a key from preview_relevant_attributes (e.g. "name")
allow(party).to receive(:saved_changes).and_return('name' => ['Old', 'New'])
expect(party.preview_content_changed?).to be true
end
it 'returns false if saved_changes do not include any preview relevant attributes' do
party = build(:party)
allow(party).to receive(:saved_changes).and_return('non_relevant' => ['A', 'B'])
expect(party.preview_content_changed?).to be false
end
end
describe '#should_generate_preview?' do
context 'when ready_for_preview? is false' do
it 'returns false regardless of preview_state' do
party = build(:party, weapons_count: 0, characters_count: 1, summons_count: 1, preview_state: 'pending')
expect(party.should_generate_preview?).to be false
end
end
context 'when preview_state is nil or pending' do
it 'returns true' do
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1, preview_state: nil)
expect(party.should_generate_preview?).to be true
party.preview_state = 'pending'
expect(party.should_generate_preview?).to be true
end
end
context "when preview_state is 'failed'" do
it 'returns true if preview_generated_at is more than 5 minutes ago' do
past_time = 6.minutes.ago
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1,
preview_state: 'failed', preview_generated_at: past_time)
expect(party.should_generate_preview?).to be true
end
end
context "when preview_state is 'generated'" do
it 'returns true if preview is expired' do
expired_time = PreviewService::Coordinator::PREVIEW_EXPIRY.ago - 1.minute
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1,
preview_state: 'generated', preview_generated_at: expired_time)
expect(party.should_generate_preview?).to be true
end
it 'returns false if preview is recent and no content change is detected' do
recent_time = Time.current - 1.minute
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1,
preview_state: 'generated', preview_generated_at: recent_time)
allow(party).to receive(:saved_changes).and_return('non_relevant' => ['A', 'B'])
expect(party.should_generate_preview?).to be false
end
it 'returns true if content has changed and preview_generated_at is more than 5 minutes ago' do
old_time = 6.minutes.ago
party = build(:party, weapons_count: 1, characters_count: 1, summons_count: 1,
preview_state: 'generated', preview_generated_at: old_time)
allow(party).to receive(:saved_changes).and_return('name' => ['Old', 'New'])
expect(party.should_generate_preview?).to be true
end
end
end
describe '#schedule_preview_generation' do
before(:all) do
ActiveJob::Base.queue_adapter = :test
end
it 'enqueues a GeneratePartyPreviewJob and sets preview_state to "queued" if not already queued or in_progress' do
# Create a party normally, then force its preview_state to "pending" (the integer value)
party = create(:party, weapons_count: 1, characters_count: 1, summons_count: 1)
party.update_column(:preview_state, Party.preview_states[:pending])
clear_enqueued_jobs
expect { party.schedule_preview_generation }
.to have_enqueued_job(GeneratePartyPreviewJob)
.with(party.id)
party.reload
expect(party.preview_state).to eq('queued')
end
it 'does nothing if preview_state is already "queued"' do
party = create(:party, weapons_count: 1, characters_count: 1, summons_count: 1, preview_state: 'queued')
clear_enqueued_jobs
expect { party.schedule_preview_generation }.not_to(change { enqueued_jobs.count })
end
it 'does nothing if preview_state is "in_progress"' do
party = create(:party, weapons_count: 1, characters_count: 1, summons_count: 1, preview_state: 'in_progress')
clear_enqueued_jobs
expect { party.schedule_preview_generation }.not_to(change { enqueued_jobs.count })
end
end
describe '#update_element!' do
it 'updates the party element if a main weapon (position -1) with a different element is present' do
# Create a party with element 3 (Water) initially.
party = create(:party, element: 3)
# Create a dummy weapon (using an instance double) with element 2 (Fire).
fire_weapon = instance_double('Weapon', element: 2)
# Create a dummy grid weapon with position -1 and the fire_weapon.
grid_weapon = instance_double('GridWeapon', position: -1, weapon: fire_weapon)
allow(party).to receive(:weapons).and_return([grid_weapon])
# Expect update_column to be called with the new element.
expect(party).to receive(:update_column).with(:element, 2)
party.send(:update_element!)
end
it 'does not update the party element if no main weapon is found' do
party = create(:party, element: 3)
allow(party).to receive(:weapons).and_return([])
expect(party).not_to receive(:update_column)
party.send(:update_element!)
end
end
describe '#update_extra!' do
it 'updates the party extra flag to true if any weapon is in an extra position' do
party = create(:party, extra: false)
grid_weapon = instance_double('GridWeapon', position: 9)
allow(party).to receive(:weapons).and_return([grid_weapon])
expect(party).to receive(:update_column).with(:extra, true)
party.send(:update_extra!)
end
it 'does not update the party extra flag if no weapon is in an extra position' do
party = create(:party, extra: false)
allow(party).to receive(:weapons).and_return([instance_double('GridWeapon', position: 0)])
expect(party).not_to receive(:update_column)
party.send(:update_extra!)
end
end
describe '#set_shortcode' do
it 'sets a shortcode of length 6' do
party = build(:party, shortcode: nil)
party.send(:set_shortcode)
expect(party.shortcode.length).to eq(6)
end
end
describe '#set_edit_key' do
it 'sets edit_key for an anonymous party (when user is nil)' do
party = build(:party, user: nil, edit_key: nil)
party.send(:set_edit_key)
expect(party.edit_key).not_to be_nil
end
it 'does not set edit_key when a user is present' do
party = build(:party, user: create(:user), edit_key: nil)
party.send(:set_edit_key)
expect(party.edit_key).to be_nil
end
end
describe '#random_string' do
it 'returns an alphanumeric string of length 6 by default' do
party = build(:party)
str = party.send(:random_string)
expect(str.length).to eq(6)
expect(str).to match(/\A[a-zA-Z0-9]+\z/)
end
end
describe '#preview_relevant_attributes' do
it 'returns an array of expected attribute names' do
party = build(:party)
expected = %w[name job_id element weapons_count characters_count summons_count full_auto auto_guard charge_attack clear_time]
expect(party.send(:preview_relevant_attributes)).to match_array(expected)
end
end
# Debug block: print debug info if an example fails.
after(:each) do |example|
if example.exception
puts "\nDEBUG [Party Model Validations]: Failed example: #{example.full_description}"
end
end
end

84
spec/rails_helper.rb Normal file
View file

@ -0,0 +1,84 @@
# frozen_string_literal: true
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
# Prevent database truncation if the environment is production
abort('The Rails environment is running in production mode!') if Rails.env.production?
# Load Rails and RSpec Rails Rails is not loaded until after the environment is set.
require 'rspec/rails'
# -----------------------------------------------------------------------------
# Additional Requires:
#
# Add any additional requires below this line. For example, if you need to load
# custom libraries or support files that are not automatically required.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Require Support Files:
#
# All files in the spec/support directory and its subdirectories (except those
# ending in _spec.rb) are automatically required here. This is useful for custom
# matchers, macros, and shared contexts.
# -----------------------------------------------------------------------------
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
# -----------------------------------------------------------------------------
# Check for Pending Migrations:
#
# This will check for any pending migrations before tests are run.
# -----------------------------------------------------------------------------
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
RSpec.configure do |config|
# Disable ActiveRecord logging during tests for a cleaner test output.
ActiveRecord::Base.logger = nil if Rails.env.test?
# -----------------------------------------------------------------------------
# Shoulda Matchers:
#
# If you use shoulda-matchers, you can configure them here. (Make sure you have
# the shoulda-matchers gem installed and configured in your Gemfile.)
# -----------------------------------------------------------------------------
require 'shoulda/matchers'
Shoulda::Matchers.configure do |matcher_config|
matcher_config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
# -----------------------------------------------------------------------------
# FactoryBot Syntax Methods:
#
# This makes methods like create and build available without needing to prefix
# them with FactoryBot.
# -----------------------------------------------------------------------------
config.include FactoryBot::Syntax::Methods
# -----------------------------------------------------------------------------
# Load canonical seed data for test environment:
#
# This ensures that your canonical CSV data is loaded before your tests run.
# -----------------------------------------------------------------------------
config.before(:suite) do
load Rails.root.join('db', 'seed', 'canonical.rb')
end
# -----------------------------------------------------------------------------
# Backtrace Filtering:
#
# Filter out lines from Rails gems in backtraces for clarity.
# -----------------------------------------------------------------------------
config.filter_rails_from_backtrace!
# You can add additional filters here if needed:
# config.filter_gems_from_backtrace("gem name")
end

View file

@ -0,0 +1,384 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'GridCharacters API', type: :request do
let(:user) { create(:user) }
let(:party) { create(:party, user: user, edit_key: 'secret') }
let(:access_token) do
Doorkeeper::AccessToken.create!(
resource_owner_id: user.id,
expires_in: 30.days,
scopes: 'public'
)
end
let(:headers) do
{
'Authorization' => "Bearer #{access_token.token}",
'Content-Type' => 'application/json'
}
end
# Using canonical data from CSV for non-user-generated models.
let(:incoming_character) { Character.find_by(granblue_id: '3040036000') }
describe 'Authorization for editing grid characters' do
context 'when the party is owned by a logged in user' do
let(:valid_params) do
{
character: {
party_id: party.id,
character_id: incoming_character.id,
position: 1,
uncap_level: 3,
transcendence_step: 0,
perpetuity: false,
rings: [{ modifier: '1', strength: '1500' }],
awakening: { id: 'character-balanced', level: 1 }
}
}
end
it 'allows the owner to create a grid character' do
expect do
post '/api/v1/characters', params: valid_params.to_json, headers: headers
end.to change(GridCharacter, :count).by(1)
expect(response).to have_http_status(:created)
end
it 'allows the owner to update a grid character' do
grid_character = create(:grid_character,
party: party,
character: incoming_character,
position: 2,
uncap_level: 3,
transcendence_step: 0)
update_params = {
character: {
id: grid_character.id,
party_id: party.id,
character_id: incoming_character.id,
position: 2,
uncap_level: 4,
transcendence_step: 1,
rings: [{ modifier: '1', strength: '1500' }, { modifier: '2', strength: '750' }],
awakening: { id: 'character-attack', level: 2 }
}
}
# Use the resource route for update (as defined by resources :grid_characters)
put "/api/v1/grid_characters/#{grid_character.id}", params: update_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['grid_character']).to include('uncap_level' => 4, 'transcendence_step' => 1)
end
it 'allows the owner to update the uncap level and transcendence step' do
grid_character = create(:grid_character,
party: party,
character: incoming_character,
position: 3,
uncap_level: 3,
transcendence_step: 0)
update_uncap_params = {
character: {
id: grid_character.id,
party_id: party.id,
character_id: incoming_character.id,
uncap_level: 5,
transcendence_step: 1
}
}
post '/api/v1/characters/update_uncap', params: update_uncap_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['grid_character']).to include('uncap_level' => 5, 'transcendence_step' => 1)
end
it 'allows the owner to resolve conflicts by replacing an existing grid character' do
# Create a conflicting grid character (same character_id) at the target position.
conflicting_character = create(:grid_character,
party: party,
character: incoming_character,
position: 4,
uncap_level: 3)
resolve_params = {
resolve: {
position: 4,
incoming: incoming_character.id,
conflicting: [conflicting_character.id]
}
}
expect do
post '/api/v1/characters/resolve', params: resolve_params.to_json, headers: headers
end.to change(GridCharacter, :count).by(0) # one record is destroyed and one is created
expect(response).to have_http_status(:created)
json_response = JSON.parse(response.body)
expect(json_response['grid_character']).to include('position' => 4)
expect { conflicting_character.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'allows the owner to destroy a grid character' do
grid_character = create(:grid_character,
party: party,
character: incoming_character,
position: 5,
uncap_level: 3)
# Using the custom route for destroy: DELETE '/api/v1/characters'
expect do
delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers
end.to change(GridCharacter, :count).by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when the party is anonymous (no user)' do
let(:anon_party) { create(:party, user: nil, edit_key: 'anonsecret') }
let(:headers) { { 'Content-Type' => 'application/json', 'X-Edit-Key' => 'anonsecret' } }
let(:valid_params) do
{
character: {
party_id: anon_party.id,
character_id: incoming_character.id,
position: 1,
uncap_level: 3,
transcendence_step: 0,
perpetuity: false,
rings: [{ modifier: '1', strength: '1500' }],
awakening: { id: 'character-balanced', level: 1 }
}
}
end
it 'allows anonymous creation with correct edit_key' do
expect do
post '/api/v1/characters', params: valid_params.to_json, headers: headers
end.to change(GridCharacter, :count).by(1)
expect(response).to have_http_status(:created)
end
context 'when an incorrect edit_key is provided' do
let(:headers) { super().merge('X-Edit-Key' => 'wrong') }
it 'returns an unauthorized response' do
post '/api/v1/characters', params: valid_params.to_json, headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
end
end
describe 'POST /api/v1/characters (create action) with invalid parameters' do
context 'with missing or invalid required fields' do
let(:invalid_params) do
{
character: {
party_id: party.id,
# Missing character_id
position: 1,
uncap_level: 2,
transcendence_step: 0
}
}
end
it 'returns unprocessable entity status with error messages' do
post '/api/v1/characters', params: invalid_params.to_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('errors')
# Verify that the error message on uncap_level includes a specific phrase.
expect(json_response['errors']['code'].to_s).to eq('no_character_provided')
end
end
end
describe 'PUT /api/v1/grid_characters/:id (update action)' do
let!(:grid_character) do
create(:grid_character,
party: party,
character: incoming_character,
position: 2,
uncap_level: 3,
transcendence_step: 0)
end
context 'with valid parameters' do
let(:update_params) do
{
character: {
id: grid_character.id,
party_id: party.id,
character_id: incoming_character.id,
position: 2,
uncap_level: 4,
transcendence_step: 1,
rings: [{ modifier: '1', strength: '1500' }, { modifier: '2', strength: '750' }],
awakening: { id: 'character-balanced', level: 2 }
}
}
end
it 'updates the grid character and returns the updated record' do
put "/api/v1/grid_characters/#{grid_character.id}", params: update_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['grid_character']).to include('uncap_level' => 4, 'transcendence_step' => 1)
end
end
context 'with invalid parameters' do
let(:invalid_update_params) do
{
character: {
id: grid_character.id,
party_id: party.id,
character_id: incoming_character.id,
position: 2,
uncap_level: 'invalid',
transcendence_step: 1
}
}
end
it 'returns unprocessable entity status with error details' do
put "/api/v1/grid_characters/#{grid_character.id}", params: invalid_update_params.to_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('errors')
expect(json_response['errors']['uncap_level'].to_s).to include('is not a number')
end
end
end
describe 'POST /api/v1/characters/update_uncap (update uncap level action)' do
let!(:grid_character) do
create(:grid_character,
party: party,
character: incoming_character,
position: 3,
uncap_level: 3,
transcendence_step: 0)
end
context 'with valid uncap level parameters' do
let(:update_uncap_params) do
{
character: {
id: grid_character.id,
party_id: party.id,
character_id: incoming_character.id,
uncap_level: 5,
transcendence_step: 1
}
}
end
it 'updates the uncap level and transcendence step' do
post '/api/v1/characters/update_uncap', params: update_uncap_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['grid_character']).to include('uncap_level' => 5, 'transcendence_step' => 1)
end
end
end
describe 'POST /api/v1/characters/resolve (conflict resolution action)' do
let!(:conflicting_character) do
create(:grid_character,
party: party,
character: incoming_character,
position: 4,
uncap_level: 3)
end
let(:resolve_params) do
{
resolve: {
position: 4,
incoming: incoming_character.id,
conflicting: [conflicting_character.id]
}
}
end
it 'resolves conflicts by replacing the existing grid character' do
expect(GridCharacter.exists?(conflicting_character.id)).to be true
expect do
post '/api/v1/characters/resolve', params: resolve_params.to_json, headers: headers
end.to change(GridCharacter, :count).by(0) # one record deleted, one created
expect(response).to have_http_status(:created)
json_response = JSON.parse(response.body)
expect(json_response['grid_character']).to include('position' => 4)
expect { conflicting_character.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
describe 'DELETE /api/v1/characters (destroy action)' do
context 'when the party is owned by a logged in user' do
let!(:grid_character) do
create(:grid_character,
party: party,
character: incoming_character,
position: 6,
uncap_level: 3)
end
it 'destroys the grid character and returns a success response' do
expect do
delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers
end.to change(GridCharacter, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'returns not found when trying to delete a non-existent grid character' do
delete '/api/v1/characters', params: { id: '00000000-0000-0000-0000-000000000000' }.to_json, headers: headers
expect(response).to have_http_status(:not_found)
end
end
context 'when the party is anonymous' do
let(:anon_party) { create(:party, user: nil, edit_key: 'anonsecret') }
let(:headers) { { 'Content-Type' => 'application/json', 'X-Edit-Key' => 'anonsecret' } }
let!(:grid_character) do
create(:grid_character,
party: anon_party,
character: incoming_character,
position: 6,
uncap_level: 3)
end
it 'allows anonymous user to destroy the grid character' do
expect do
delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: headers
end.to change(GridCharacter, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'prevents deletion when a logged in user attempts to delete an anonymous grid character' do
auth_headers = headers.except('X-Edit-Key')
expect do
delete '/api/v1/characters', params: { id: grid_character.id }.to_json, headers: auth_headers
end.not_to change(GridCharacter, :count)
expect(response).to have_http_status(:unauthorized)
end
end
end
# Debug hook: if any example fails and a response exists, print the error message.
after(:each) do |example|
if example.exception && defined?(response) && response.present?
error_message = begin
JSON.parse(response.body)['exception']
rescue JSON::ParserError
response.body
end
puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}"
# Parse once and grab the trace safely
parsed_body = JSON.parse(response.body)
trace = parsed_body.dig('traces', 'Application Trace')
ap trace if trace # Only print if trace is not nil
end
end
end

View file

@ -0,0 +1,386 @@
# frozen_string_literal: true
require 'rails_helper'
# This request spec tests the GridSummons API endpoints using modern RSpec techniques.
RSpec.describe 'GridSummons API', type: :request do
let(:user) { create(:user) }
let(:party) { create(:party, user: user, edit_key: 'secret') }
let(:summon) { Summon.find_by(granblue_id: '2040433000') }
let(:access_token) do
Doorkeeper::AccessToken.create!(
resource_owner_id: user.id,
expires_in: 30.days,
scopes: 'public'
)
end
let(:headers) do
{
'Authorization' => "Bearer #{access_token.token}",
'Content-Type' => 'application/json'
}
end
RSpec::Matchers.define :have_json_error_message do |expected_message|
match do |response|
JSON.parse(response.body)['error'].to_s.include?(expected_message)
end
failure_message do |response|
"expected error message to include '#{expected_message}', but got: #{JSON.parse(response.body)['error']}"
end
end
describe 'POST /api/v1/summons' do
let(:valid_params) do
{
summon: {
party_id: party.id,
summon_id: summon.id,
position: 0,
main: true,
friend: false,
quick_summon: false,
uncap_level: 3,
transcendence_step: 0
}
}
end
context 'with valid parameters' do
it 'creates a grid summon and returns status created' do
expect do
post '/api/v1/summons', params: valid_params.to_json, headers: headers
end.to change(GridSummon, :count).by(1)
expect(response).to have_http_status(:created)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_summon')
expect(json_response['grid_summon']).to include('position' => 0)
end
end
context 'with invalid parameters' do
# Revised: use a non-numeric uncap_level so the grid summon is built but fails validation.
let(:invalid_params) do
{
summon: {
party_id: party.id,
summon_id: summon.id,
position: 0,
main: true,
friend: false,
quick_summon: false,
uncap_level: 'invalid',
transcendence_step: 0
}
}
end
it 'returns unprocessable entity status with error details' do
post '/api/v1/summons', params: invalid_params.to_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('errors')
expect(json_response['errors']).to include('uncap_level')
end
end
end
describe 'PUT /api/v1/grid_summons/:id' do
before do
@grid_summon = create(:grid_summon,
party: party,
summon: summon,
position: 1,
uncap_level: 3,
transcendence_step: 0)
end
context 'with valid parameters' do
let(:update_params) do
{
summon: {
id: @grid_summon.id,
party_id: party.id,
summon_id: summon.id,
position: 1,
main: true,
friend: false,
quick_summon: false,
uncap_level: 4,
transcendence_step: 0
}
}
end
it 'updates the grid summon and returns the updated record' do
put "/api/v1/grid_summons/#{@grid_summon.id}", params: update_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_summon')
expect(json_response['grid_summon']).to include('uncap_level' => 4)
end
end
context 'with invalid parameters' do
let(:invalid_update_params) do
{
summon: {
id: @grid_summon.id,
party_id: party.id,
summon_id: summon.id,
position: 1,
main: true,
friend: false,
quick_summon: false,
uncap_level: 'invalid',
transcendence_step: 0
}
}
end
it 'returns unprocessable entity status with error details' do
put "/api/v1/grid_summons/#{@grid_summon.id}", params: invalid_update_params.to_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('errors')
expect(json_response['errors']).to include('uncap_level')
end
end
end
describe 'POST /api/v1/summons/update_uncap' do
context 'when summon has flb true, ulb false, transcendence false (max uncap 4)' do
before do
@grid_summon = create(:grid_summon,
party: party,
summon: summon,
position: 2,
uncap_level: 3,
transcendence_step: 0)
end
let(:update_uncap_params) do
{
summon: {
id: @grid_summon.id,
party_id: party.id,
summon_id: summon.id,
uncap_level: 5, # attempt above allowed; should be capped at 4
transcendence_step: 0
}
}
end
before do
summon.update!(flb: true, ulb: false, transcendence: false)
end
it 'caps the uncap level at 4 for the summon' do
post '/api/v1/summons/update_uncap', params: update_uncap_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_summon')
expect(json_response['grid_summon']).to include('uncap_level' => 4)
end
end
context 'when summon has ulb true, transcendence false (max uncap 5)' do
before do
@grid_summon = create(:grid_summon,
party: party,
summon: summon,
position: 2,
uncap_level: 3,
transcendence_step: 0)
end
let(:update_uncap_params) do
{
summon: {
id: @grid_summon.id,
party_id: party.id,
summon_id: summon.id,
uncap_level: 6, # attempt above allowed; should be capped at 5
transcendence_step: 0
}
}
end
before do
summon.update!(flb: true, ulb: true, transcendence: false)
end
it 'updates the uncap level to 5' do
post '/api/v1/summons/update_uncap', params: update_uncap_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_summon')
expect(json_response['grid_summon']).to include('uncap_level' => 5)
end
end
context 'when summon can be transcended (max uncap 6)' do
before do
@grid_summon = create(:grid_summon,
party: party,
summon: summon,
position: 2,
uncap_level: 3,
transcendence_step: 0)
end
let(:update_uncap_params) do
{
summon: {
id: @grid_summon.id,
party_id: party.id,
summon_id: summon.id,
uncap_level: 7, # attempt above allowed; should be capped at 6
transcendence_step: 0
}
}
end
before do
summon.update!(flb: true, ulb: true, transcendence: true)
end
it 'updates the uncap level to 6' do
post '/api/v1/summons/update_uncap', params: update_uncap_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_summon')
expect(json_response['grid_summon']).to include('uncap_level' => 6)
end
end
end
describe 'POST /api/v1/summons/update_quick_summon' do
context 'when grid summon position is not in [4,5,6]' do
let!(:grid_summon) do
create(:grid_summon,
party: party,
summon: summon,
position: 2,
quick_summon: false)
end
let(:update_quick_params) do
{
summon: {
id: grid_summon.id,
party_id: party.id,
summon_id: summon.id,
quick_summon: true
}
}
end
it 'updates the quick summon flag and returns the updated summons array' do
post '/api/v1/summons/update_quick_summon', params: update_quick_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('summons')
end
end
context 'when grid summon position is in [4,5,6]' do
let!(:grid_summon) do
create(:grid_summon,
party: party,
summon: summon,
position: 4,
quick_summon: false)
end
let(:update_quick_params) do
{
summon: {
id: grid_summon.id,
party_id: party.id,
summon_id: summon.id,
quick_summon: true
}
}
end
it 'returns no content when position is in [4,5,6]' do
post '/api/v1/summons/update_quick_summon', params: update_quick_params.to_json, headers: headers
expect(response).to have_http_status(:no_content)
end
end
end
describe 'DELETE /api/v1/grid_summons/:id (destroy action)' do
context 'when the party is owned by a logged in user' do
let!(:grid_summon) do
create(:grid_summon,
party: party,
summon: summon,
position: 3,
uncap_level: 3,
transcendence_step: 0)
end
it 'destroys the grid summon and returns a success response' do
expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: headers }
.to change(GridSummon, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'returns not found when trying to delete a non-existent grid summon' do
delete '/api/v1/grid_summons/00000000-0000-0000-0000-000000000000', headers: headers
expect(response).to have_http_status(:not_found)
end
end
context 'when the party is anonymous (no user)' do
# For anonymous users, override the party and header edit key.
let(:headers) { super().merge('X-Edit-Key' => 'anonsecret') }
let(:party) { create(:party, user: nil, edit_key: 'anonsecret') }
let!(:grid_summon) do
create(:grid_summon,
party: party,
summon: summon,
position: 3,
uncap_level: 3,
transcendence_step: 0)
end
it 'allows anonymous user to destroy grid summon when current_user is nil' do
# To simulate an anonymous request, we remove the Authorization header.
anonymous_headers = headers.except('Authorization')
expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: anonymous_headers }
.to change(GridSummon, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'prevents deletion when a logged in user attempts to delete an anonymous grid summon' do
# When a logged in user (with an access token) tries to delete a grid summon
# that belongs to an anonymous party, authorization should fail.
auth_headers = headers.except('X-Edit-Key')
expect { delete "/api/v1/grid_summons/#{grid_summon.id}", headers: auth_headers }
.not_to change(GridSummon, :count)
expect(response).to have_http_status(:unauthorized)
end
end
end
# Debug hook: if any example fails and a response exists, print the error message.
after(:each) do |example|
if example.exception && defined?(response) && response.present?
error_message = begin
JSON.parse(response.body)['exception']
rescue JSON::ParserError
response.body
end
puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}"
# Parse once and grab the trace safely
parsed_body = JSON.parse(response.body)
trace = parsed_body.dig('traces', 'Application Trace')
ap trace if trace # Only print if trace is not nil
end
end
end

View file

@ -0,0 +1,364 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'GridWeapons API', type: :request do
let(:user) { create(:user) }
# By default, we create a party owned by the user with edit_key 'secret'
let(:party) { create(:party, user: user, edit_key: 'secret') }
let(:access_token) do
Doorkeeper::AccessToken.create!(
resource_owner_id: user.id,
expires_in: 30.days,
scopes: 'public'
)
end
let(:headers) do
{
'Authorization' => "Bearer #{access_token.token}",
'Content-Type' => 'application/json',
}
end
let(:weapon) { Weapon.find_by!(granblue_id: '1040611300') }
let(:incoming_weapon) { Weapon.find_by!(granblue_id: '1040912100') }
describe 'Authorization for editing grid weapons' do
context 'when the party is owned by a logged in user' do
let(:weapon_params) do
{
weapon: {
party_id: party.id,
weapon_id: weapon.id,
position: 0,
mainhand: true,
uncap_level: 3,
transcendence_step: 0,
element: weapon.element,
}
}
end
it 'allows the owner to create a grid weapon' do
expect do
post '/api/v1/weapons', params: weapon_params.to_json, headers: headers
end.to change(GridWeapon, :count).by(1)
expect(response).to have_http_status(:created)
end
it 'rejects a logged-in user that does not own the party' do
# Create a party owned by a different user.
other_user = create(:user)
party_owned_by_other = create(:party, user: other_user, edit_key: 'secret')
weapon_params[:weapon][:party_id] = party_owned_by_other.id
post '/api/v1/weapons', params: weapon_params.to_json, headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the party is anonymous (no user)' do
# Override party to be anonymous with its own edit_key.
let(:headers) { super().merge('X-Edit-Key' => 'anonsecret') }
let(:party) { create(:party, user: nil, edit_key: 'anonsecret') }
let(:anon_params) do
{
weapon: {
party_id: party.id,
weapon_id: weapon.id,
position: 0,
mainhand: true,
uncap_level: 3,
transcendence_step: 0,
element: weapon.element,
}
}
end
it 'allows editing with correct edit_key' do
expect { post '/api/v1/weapons', params: anon_params.to_json, headers: headers }
.to change(GridWeapon, :count).by(1)
expect(response).to have_http_status(:created)
end
context 'when an incorrect edit_key is provided' do
# Override the edit_key (simulate invalid key)
let(:headers) { super().merge('X-Edit-Key' => 'wrong') }
it 'returns an unauthorized response' do
post '/api/v1/weapons', params: anon_params.to_json, headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
end
end
describe 'POST /api/v1/weapons (create action)' do
context 'with valid parameters' do
let(:valid_params) do
{
weapon: {
party_id: party.id,
weapon_id: weapon.id,
position: 0,
mainhand: true,
uncap_level: 3,
transcendence_step: 0,
element: weapon.element,
weapon_key1_id: nil,
weapon_key2_id: nil,
weapon_key3_id: nil,
ax_modifier1: nil,
ax_modifier2: nil,
ax_strength1: nil,
ax_strength2: nil,
awakening_id: nil,
awakening_level: 1,
}
}
end
it 'creates a grid weapon and returns status created' do
expect { post '/api/v1/weapons', params: valid_params.to_json, headers: headers }
.to change(GridWeapon, :count).by(1)
expect(response).to have_http_status(:created)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_weapon')
expect(json_response['grid_weapon']).to include('position' => 0)
end
end
context 'with invalid parameters' do
let(:invalid_params) do
{
weapon: {
party_id: party.id,
weapon_id: nil, # Missing required weapon_id
position: 0,
mainhand: true,
uncap_level: 3,
transcendence_step: 0
}
}
end
it 'returns unprocessable entity status with errors' do
post '/api/v1/weapons', params: invalid_params.to_json, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('errors')
end
end
context 'when unauthorized (invalid edit key)' do
# For this test, use an anonymous party so that edit key checking is applied.
let(:party) { create(:party, user: nil, edit_key: 'anonsecret') }
let(:valid_params) do
{
weapon: {
party_id: party.id,
weapon_id: weapon.id,
position: 0,
mainhand: true,
uncap_level: 3,
transcendence_step: 0,
element: weapon.element,
}
}
end
let(:unauthorized_headers) { headers.merge('X-Edit-Key' => 'wrong') }
it 'returns an unauthorized response' do
post '/api/v1/weapons', params: valid_params.to_json, headers: unauthorized_headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/grid_weapons/:id (update action)' do
let!(:grid_weapon) do
create(:grid_weapon,
party: party,
weapon: weapon,
position: 2,
uncap_level: 3,
transcendence_step: 0,
mainhand: false)
end
let(:update_params) do
{
weapon: {
id: grid_weapon.id,
party_id: party.id,
weapon_id: weapon.id,
position: 2,
mainhand: false,
uncap_level: 4,
transcendence_step: 1,
element: weapon.element,
weapon_key1_id: nil,
weapon_key2_id: nil,
weapon_key3_id: nil,
ax_modifier1: nil,
ax_modifier2: nil,
ax_strength1: nil,
ax_strength2: nil,
awakening_id: nil,
awakening_level: 1
}
}
end
it 'updates the grid weapon and returns the updated record' do
put "/api/v1/grid_weapons/#{grid_weapon.id}", params: update_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['grid_weapon']).to include('mainhand' => false, 'uncap_level' => 4)
end
end
describe 'POST /api/v1/weapons/update_uncap (update uncap level action)' do
before do
# For this test, update the weapon so that its conditions dictate a maximum uncap of 5.
weapon.update!(flb: false, ulb: true, transcendence: false)
end
let!(:grid_weapon) do
create(:grid_weapon,
party: party,
weapon: weapon,
position: 3,
uncap_level: 3,
transcendence_step: 0)
end
let(:update_uncap_params) do
{
weapon: {
id: grid_weapon.id, # now nested inside the weapon hash
party_id: party.id,
weapon_id: weapon.id,
uncap_level: 6, # attempt above allowed; should be capped at 5
transcendence_step: 0
}
}
end
it 'updates the uncap level to 5 for the grid weapon' do
post '/api/v1/weapons/update_uncap', params: update_uncap_params.to_json, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['grid_weapon']).to include('uncap_level' => 5)
end
end
describe 'POST /api/v1/weapons/resolve (conflict resolution action)' do
let!(:conflicting_weapon) do
create(:grid_weapon,
party: party,
weapon: weapon,
position: 5,
uncap_level: 3)
end
before do
# Set up the incoming weapon with flags such that: default uncap is 3,
# but if flb is true then uncap should become 4.
incoming_weapon.update!(flb: true, ulb: false, transcendence: false)
end
let(:resolve_params) do
{
resolve: {
position: 5,
incoming: incoming_weapon.id,
conflicting: [conflicting_weapon.id]
}
}
end
it 'resolves conflicts by destroying conflicting grid weapons and creating a new one' do
expect(GridWeapon.exists?(conflicting_weapon.id)).to be true
# The net change should be zero: one grid weapon is destroyed and one is created.
expect { post '/api/v1/weapons/resolve', params: resolve_params.to_json, headers: headers }
.to change(GridWeapon, :count).by(0)
expect(response).to have_http_status(:created)
json_response = JSON.parse(response.body)
expect(json_response).to have_key('grid_weapon')
# According to the controller logic, with incoming.flb true, the uncap level should be 4.
expect(json_response['grid_weapon']).to include('uncap_level' => 4)
expect { conflicting_weapon.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
describe 'DELETE /api/v1/grid_weapons/:id (destroy action)' do
context 'when the party is owned by a logged in user' do
let!(:grid_weapon) do
create(:grid_weapon,
party: party,
weapon: weapon,
position: 4,
uncap_level: 3)
end
it 'destroys the grid weapon and returns a success response' do
expect { delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: headers }
.to change(GridWeapon, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'returns not found when trying to delete a non-existent grid weapon' do
delete '/api/v1/grid_weapons/00000000-0000-0000-0000-000000000000', headers: headers
expect(response).to have_http_status(:not_found)
end
end
context 'when the party is anonymous (no user)' do
# For anonymous users, we override both the party and header edit key.
let(:headers) { super().merge('X-Edit-Key' => 'anonsecret') }
let(:party) { create(:party, user: nil, edit_key: 'anonsecret') }
let!(:grid_weapon) do
create(:grid_weapon,
party: party,
weapon: weapon,
position: 4,
uncap_level: 3)
end
it 'allows anonymous user to destroy grid weapon with correct edit key' do
expect { delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: headers }
.to change(GridWeapon, :count).by(-1)
expect(response).to have_http_status(:ok)
end
it 'prevents destruction with incorrect edit key' do
wrong_headers = headers.merge('X-Edit-Key' => 'wrong')
delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: wrong_headers
expect(response).to have_http_status(:unauthorized)
end
it 'prevents deletion when a logged in user attempts to delete an anonymous grid weapon' do
# When a logged in user (with an access token) tries to delete a grid weapon
# that belongs to an anonymous party, authorization should fail.
auth_headers = headers.except('X-Edit-Key')
expect { delete "/api/v1/grid_weapons/#{grid_weapon.id}", headers: auth_headers }
.not_to change(GridWeapon, :count)
expect(response).to have_http_status(:unauthorized)
end
end
end
# Debug hook: if any example fails and a response exists, print the error message.
after(:each) do |example|
if example.exception && defined?(response) && response.present?
error_message = begin
JSON.parse(response.body)['exception']
rescue JSON::ParserError
response.body
end
puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}"
# Parse once and grab the trace safely
parsed_body = JSON.parse(response.body)
trace = parsed_body.dig('traces', 'Application Trace')
ap trace if trace # Only print if trace is not nil
end
end
end

View file

@ -0,0 +1,205 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Parties API', type: :request do
let(:user) { create(:user) }
let(:access_token) do
Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public')
end
let(:headers) do
{ 'Authorization' => "Bearer #{access_token.token}", 'Content-Type' => 'application/json' }
end
describe 'POST /api/v1/parties' do
context 'with valid attributes' do
let(:valid_attributes) do
{
party: {
name: 'Test Party',
description: 'A party for testing',
raid_id: nil,
visibility: 1,
full_auto: false,
auto_guard: false,
charge_attack: true,
clear_time: 500,
button_count: 3,
turn_count: 4,
chain_count: 2
}
}
end
it 'creates a new party and returns status created' do
expect do
post '/api/v1/parties', params: valid_attributes.to_json, headers: headers
end.to change(Party, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['party']['name']).to eq('Test Party')
end
end
end
describe 'GET /api/v1/parties/:id' do
let!(:party) { create(:party, user: user, name: 'Visible Party', visibility: 1) }
context 'when the party is public or owned' do
it 'returns the party details' do
get "/api/v1/parties/#{party.shortcode}", headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['party']['name']).to eq('Visible Party')
end
end
context 'when the party is private and not owned' do
let!(:private_party) { create(:party, user: create(:user), visibility: 3, name: 'Private Party') }
it 'returns unauthorized' do
get "/api/v1/parties/#{private_party.shortcode}", headers: headers
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/parties/:id' do
let!(:party) { create(:party, user: user, name: 'Old Name') }
let(:update_attributes) do
{ party: { name: 'New Name', description: 'Updated description' } }
end
it 'updates the party and returns the updated party' do
put "/api/v1/parties/#{party.id}", params: update_attributes.to_json, headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['party']['name']).to eq('New Name')
expect(json['party']['description']).to eq('Updated description')
end
end
describe 'DELETE /api/v1/parties/:id' do
let!(:party) { create(:party, user: user) }
it 'destroys the party and returns the destroyed party view' do
delete "/api/v1/parties/#{party.id}", headers: headers
expect(response).to have_http_status(:ok)
expect { party.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
describe 'POST /api/v1/parties/:id/remix' do
let!(:party) { create(:party, user: user, name: 'Original Party') }
let(:remix_params) { { party: { local_id: party.local_id } } }
it 'creates a remixed copy of the party' do
post "/api/v1/parties/#{party.shortcode}/remix", params: remix_params.to_json, headers: headers
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['party']['source_party']['id']).to eq(party.id)
end
end
describe 'GET /api/v1/parties' do
before { create_list(:party, 3, user: user, visibility: 1) }
it 'lists parties with pagination' do
get '/api/v1/parties', headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['results']).to be_an(Array)
expect(json['meta']).to have_key('count')
end
end
describe 'GET /api/v1/parties/favorites' do
let(:other_user) { create(:user) }
let!(:party) { create(:party, user: other_user, visibility: 1) }
before do
# Create associated records so that the party meets the default filtering minimums:
# - At least 3 characters,
# - At least 5 weapons,
# - At least 2 summons.
create_list(:grid_character, 3, party: party)
create_list(:grid_weapon, 5, party: party)
create_list(:grid_summon, 2, party: party)
party.reload # Reload to update counter caches.
ap "DEBUG: Party counts - characters: #{party.characters_count}, weapons: #{party.weapons_count}, summons: #{party.summons_count}"
create(:favorite, user: user, party: party)
end
before { create(:favorite, user: user, party: party) }
it 'lists parties favorited by the current user' do
# Debug: print IDs returned by the join query (this code can be removed later)
favorite_ids = Party.joins(:favorites).where(favorites: { user_id: user.id }).distinct.pluck(:id)
ap "DEBUG: Created party id: #{party.id}"
ap "DEBUG: Favorite party ids: #{favorite_ids.inspect}"
get '/api/v1/parties/favorites', headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['results']).not_to be_empty
expect(json['results'].first).to include('favorited' => true)
end
end
describe 'Preview Management Endpoints' do
let(:user) { create(:user) }
let!(:party) { create(:party, user: user, shortcode: 'PREV01', element: 'default') }
let(:headers) do
{ 'Authorization' => "Bearer #{Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: 30.days, scopes: 'public').token}",
'Content-Type' => 'application/json' }
end
describe 'GET /api/v1/parties/:id/preview' do
before do
# Stub send_file on the correctly namespaced controller.
allow_any_instance_of(Api::V1::PartiesController).to receive(:send_file) do |instance, *args|
instance.render plain: 'dummy image content', content_type: 'image/png', status: 200
end
end
it 'serves the preview image (returns 200)' do
get "/api/v1/parties/#{party.shortcode}/preview", headers: headers
expect(response).to have_http_status(200)
expect(response.content_type).to eq('image/png; charset=utf-8')
expect(response.body).to eq('dummy image content')
end
end
describe 'GET /api/v1/parties/:id/preview_status' do
it 'returns the preview status of the party' do
get "/api/v1/parties/#{party.shortcode}/preview_status", headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json).to have_key('state')
end
end
describe 'POST /api/v1/parties/:id/regenerate_preview' do
it 'forces preview regeneration when requested by the owner' do
post "/api/v1/parties/#{party.shortcode}/regenerate_preview", headers: headers
expect(response.status).to(satisfy { |s| [200, 422].include?(s) })
end
end
end
# Debug block: prints debug info if an example fails.
after(:each) do |example|
if example.exception && defined?(response) && response.present?
error_message = begin
JSON.parse(response.body)['exception']
rescue JSON::ParserError
response.body
end
puts "\nDEBUG: Error Message for '#{example.full_description}': #{error_message}"
# Parse once and grab the trace safely
parsed_body = JSON.parse(response.body)
trace = parsed_body.dig('traces', 'Application Trace')
ap trace if trace # Only print if trace is not nil
end
end
end

100
spec/spec_helper.rb Normal file
View file

@ -0,0 +1,100 @@
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
# This file was generated by the `rails generate rspec:install` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
# The generated `.rspec` file contains `--require spec_helper` which will cause
# this file to always be loaded, without a need to explicitly require it in any
# files.
#
# Given that it is always loaded, you are encouraged to keep this file as
# light-weight as possible. Requiring heavyweight dependencies from this file
# will add to the boot time of your test suite on EVERY test run, even for an
# individual file that may not need all of that loaded. Instead, consider making
# a separate helper file that requires the additional dependencies and performs
# the additional setup, and require it from the spec files that actually need
# it.
#
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
# assertion/expectation library such as wrong or the stdlib/minitest
# assertions if you prefer.
config.expect_with :rspec do |expectations|
# This option will default to `true` in RSpec 4. It makes the `description`
# and `failure_message` of custom matchers include text for helper methods
# defined using `chain`, e.g.:
# be_bigger_than(2).and_smaller_than(4).description
# # => "be bigger than 2 and smaller than 4"
# ...rather than:
# # => "be bigger than 2"
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
# rspec-mocks config goes here. You can use an alternate test double
# library (such as bogus or mocha) by changing the `mock_with` option here.
config.mock_with :rspec do |mocks|
# Prevents you from mocking or stubbing a method that does not exist on
# a real object. This is generally recommended, and will default to
# `true` in RSpec 4.
mocks.verify_partial_doubles = true
end
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
# have no way to turn it off -- the option exists only for backwards
# compatibility in RSpec 3). It causes shared context metadata to be
# inherited by the metadata hash of host groups and examples, rather than
# triggering implicit auto-inclusion in groups with matching metadata.
config.shared_context_metadata_behavior = :apply_to_host_groups
# The settings below are suggested to provide a good initial experience
# with RSpec, but feel free to customize to your heart's content.
=begin
# This allows you to limit a spec run to individual examples or groups
# you care about by tagging them with `:focus` metadata. When nothing
# is tagged with `:focus`, all examples get run. RSpec also provides
# aliases for `it`, `describe`, and `context` that include `:focus`
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
config.filter_run_when_matching :focus
# Allows RSpec to persist some state between runs in order to support
# the `--only-failures` and `--next-failure` CLI options. We recommend
# you configure your source control system to ignore this file.
config.example_status_persistence_file_path = "spec/examples.txt"
# Limits the available syntax to the non-monkey patched syntax that is
# recommended. For more details, see:
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
config.disable_monkey_patching!
# Many RSpec users commonly either run the entire suite or an individual
# file, and it's useful to allow more verbose output when running an
# individual spec file.
if config.files_to_run.one?
# Use the documentation formatter for detailed output,
# unless a formatter has already been configured
# (e.g. via a command-line flag).
config.default_formatter = "doc"
end
# Print the 10 slowest examples and example groups at the
# end of the spec run, to help surface which specs are running
# particularly slow.
config.profile_examples = 10
# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
# the seed, which is printed after each run.
# --seed 1234
config.order = :random
# Seed global randomization in this process using the `--seed` CLI option.
# Setting this allows you to use `--seed` to deterministically reproduce
# test failures related to randomization by passing the same `--seed` value
# as the one that triggered the failure.
Kernel.srand config.seed
=end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
# This custom matcher checks that a model's errors on a given attribute include a specific phrase.
RSpec::Matchers.define :have_error_on do |attribute, expected_phrase|
match do |model|
model.valid? && model.errors[attribute].any? { |msg| msg.include?(expected_phrase) }
end
failure_message do |model|
"expected errors on #{attribute} to include '#{expected_phrase}', but got: #{model.errors[attribute].join(', ')}"
end
end