fix blueprints: use correct association names instead of 'object'
This commit is contained in:
parent
144b5cab58
commit
be5be0c3fe
81 changed files with 11414 additions and 124 deletions
4
.env
4
.env
|
|
@ -1 +1,5 @@
|
|||
RAILS_LOG_TO_STDOUT=true
|
||||
|
||||
OPENAI_API_KEY="not-needed-for-local"
|
||||
OPENAI_BASE_URL="http://192.168.1.246:8000/v1"
|
||||
OPENAI_MODEL="cpatonn/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit"
|
||||
|
|
|
|||
24
app/blueprints/api/v1/collection_character_blueprint.rb
Normal file
24
app/blueprints/api/v1/collection_character_blueprint.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionCharacterBlueprint < ApiBlueprint
|
||||
identifier :id
|
||||
|
||||
fields :uncap_level, :transcendence_step, :perpetuity,
|
||||
:ring1, :ring2, :ring3, :ring4, :earring,
|
||||
:created_at, :updated_at
|
||||
|
||||
field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(obj.awakening),
|
||||
level: obj.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
association :character, blueprint: CharacterBlueprint
|
||||
|
||||
view :full do
|
||||
association :character, blueprint: CharacterBlueprint, view: :full
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
11
app/blueprints/api/v1/collection_job_accessory_blueprint.rb
Normal file
11
app/blueprints/api/v1/collection_job_accessory_blueprint.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionJobAccessoryBlueprint < ApiBlueprint
|
||||
identifier :id
|
||||
|
||||
fields :created_at, :updated_at
|
||||
|
||||
association :job_accessory, blueprint: JobAccessoryBlueprint
|
||||
end
|
||||
end
|
||||
end
|
||||
16
app/blueprints/api/v1/collection_summon_blueprint.rb
Normal file
16
app/blueprints/api/v1/collection_summon_blueprint.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionSummonBlueprint < ApiBlueprint
|
||||
identifier :id
|
||||
|
||||
fields :uncap_level, :transcendence_step,
|
||||
:created_at, :updated_at
|
||||
|
||||
association :summon, blueprint: SummonBlueprint
|
||||
|
||||
view :full do
|
||||
association :summon, blueprint: SummonBlueprint, view: :full
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
32
app/blueprints/api/v1/collection_weapon_blueprint.rb
Normal file
32
app/blueprints/api/v1/collection_weapon_blueprint.rb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionWeaponBlueprint < ApiBlueprint
|
||||
identifier :id
|
||||
|
||||
fields :uncap_level, :transcendence_step, :element,
|
||||
:created_at, :updated_at
|
||||
|
||||
field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj|
|
||||
[
|
||||
{ modifier: obj.ax_modifier1, strength: obj.ax_strength1 },
|
||||
{ modifier: obj.ax_modifier2, strength: obj.ax_strength2 }
|
||||
].compact_blank
|
||||
end
|
||||
|
||||
field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(obj.awakening),
|
||||
level: obj.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
association :weapon, blueprint: WeaponBlueprint
|
||||
association :weapon_keys, blueprint: WeaponKeyBlueprint,
|
||||
if: ->(_, obj, _) { obj.weapon_keys.any? }
|
||||
|
||||
view :full do
|
||||
association :weapon, blueprint: WeaponBlueprint, view: :full
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -10,12 +10,12 @@ module Api
|
|||
end
|
||||
|
||||
view :preview do
|
||||
association :character, name: :object, blueprint: CharacterBlueprint
|
||||
association :character, blueprint: CharacterBlueprint
|
||||
end
|
||||
|
||||
view :nested do
|
||||
include_view :mastery_bonuses
|
||||
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
|
||||
association :character, blueprint: CharacterBlueprint, view: :full
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ module Api
|
|||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
|
||||
view :preview do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
association :summon, blueprint: SummonBlueprint
|
||||
end
|
||||
|
||||
view :nested do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
|
||||
association :summon, blueprint: SummonBlueprint, view: :full
|
||||
end
|
||||
|
||||
view :full do
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ module Api
|
|||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||
|
||||
view :preview do
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
association :weapon, blueprint: WeaponBlueprint
|
||||
end
|
||||
|
||||
view :nested do
|
||||
|
|
@ -24,7 +24,7 @@ module Api
|
|||
}
|
||||
end
|
||||
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
|
||||
association :weapon, blueprint: WeaponBlueprint, view: :full,
|
||||
if: ->(_field_name, w, _options) { w.weapon.present? }
|
||||
|
||||
association :weapon_keys,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ module Api
|
|||
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
|
||||
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response
|
||||
|
||||
# Collection errors
|
||||
rescue_from CollectionErrors::CollectionError do |e|
|
||||
render json: e.to_hash, status: e.http_status
|
||||
end
|
||||
|
||||
rescue_from GranblueError do |e|
|
||||
render_error(e)
|
||||
end
|
||||
|
|
@ -157,6 +162,17 @@ module Api
|
|||
ensure
|
||||
Prosopite.finish
|
||||
end
|
||||
|
||||
# Returns pagination metadata for will_paginate collections
|
||||
# @param collection [ActiveRecord::Relation] Paginated collection using will_paginate
|
||||
# @return [Hash] Pagination metadata with count, total_pages, and per_page
|
||||
def pagination_meta(collection)
|
||||
{
|
||||
count: collection.total_entries,
|
||||
total_pages: collection.total_pages,
|
||||
per_page: collection.limit_value || collection.per_page
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
86
app/controllers/api/v1/collection_characters_controller.rb
Normal file
86
app/controllers/api/v1/collection_characters_controller.rb
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionCharactersController < ApiController
|
||||
before_action :restrict_access
|
||||
before_action :set_collection_character, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@collection_characters = current_user.collection_characters
|
||||
.includes(:character, :awakening)
|
||||
|
||||
# Apply filters
|
||||
@collection_characters = @collection_characters.by_element(params[:element]) if params[:element]
|
||||
@collection_characters = @collection_characters.by_rarity(params[:rarity]) if params[:rarity]
|
||||
|
||||
# Apply pagination
|
||||
@collection_characters = @collection_characters.paginate(page: params[:page], per_page: params[:limit] || 50)
|
||||
|
||||
render json: Api::V1::CollectionCharacterBlueprint.render(
|
||||
@collection_characters,
|
||||
root: :characters,
|
||||
meta: pagination_meta(@collection_characters)
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
render json: Api::V1::CollectionCharacterBlueprint.render(
|
||||
@collection_character,
|
||||
view: :full
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@collection_character = current_user.collection_characters.build(collection_character_params)
|
||||
|
||||
if @collection_character.save
|
||||
render json: Api::V1::CollectionCharacterBlueprint.render(
|
||||
@collection_character,
|
||||
view: :full
|
||||
), status: :created
|
||||
else
|
||||
# Check for duplicate character error
|
||||
if @collection_character.errors[:character_id].any? { |e| e.include?('already exists') }
|
||||
raise CollectionErrors::DuplicateCharacter.new(@collection_character.character_id)
|
||||
end
|
||||
render_validation_error_response(@collection_character)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @collection_character.update(collection_character_params)
|
||||
render json: Api::V1::CollectionCharacterBlueprint.render(
|
||||
@collection_character,
|
||||
view: :full
|
||||
)
|
||||
else
|
||||
render_validation_error_response(@collection_character)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@collection_character.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection_character
|
||||
@collection_character = current_user.collection_characters.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
raise CollectionErrors::CollectionItemNotFound.new('character', params[:id])
|
||||
end
|
||||
|
||||
def collection_character_params
|
||||
params.require(:collection_character).permit(
|
||||
:character_id, :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]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
79
app/controllers/api/v1/collection_controller.rb
Normal file
79
app/controllers/api/v1/collection_controller.rb
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionController < ApiController
|
||||
before_action :set_user
|
||||
before_action :check_collection_access
|
||||
|
||||
# GET /api/v1/users/:user_id/collection
|
||||
# GET /api/v1/users/:user_id/collection?type=weapons
|
||||
def show
|
||||
collection = case params[:type]
|
||||
when 'characters'
|
||||
{
|
||||
characters: Api::V1::CollectionCharacterBlueprint.render_as_hash(
|
||||
@user.collection_characters.includes(:character, :awakening),
|
||||
view: :full
|
||||
)
|
||||
}
|
||||
when 'weapons'
|
||||
{
|
||||
weapons: Api::V1::CollectionWeaponBlueprint.render_as_hash(
|
||||
@user.collection_weapons.includes(:weapon, :awakening, :weapon_key1,
|
||||
:weapon_key2, :weapon_key3, :weapon_key4),
|
||||
view: :full
|
||||
)
|
||||
}
|
||||
when 'summons'
|
||||
{
|
||||
summons: Api::V1::CollectionSummonBlueprint.render_as_hash(
|
||||
@user.collection_summons.includes(:summon),
|
||||
view: :full
|
||||
)
|
||||
}
|
||||
when 'job_accessories'
|
||||
{
|
||||
job_accessories: Api::V1::CollectionJobAccessoryBlueprint.render_as_hash(
|
||||
@user.collection_job_accessories.includes(job_accessory: :job)
|
||||
)
|
||||
}
|
||||
else
|
||||
# Return complete collection
|
||||
{
|
||||
characters: Api::V1::CollectionCharacterBlueprint.render_as_hash(
|
||||
@user.collection_characters.includes(:character, :awakening),
|
||||
view: :full
|
||||
),
|
||||
weapons: Api::V1::CollectionWeaponBlueprint.render_as_hash(
|
||||
@user.collection_weapons.includes(:weapon, :awakening, :weapon_key1,
|
||||
:weapon_key2, :weapon_key3, :weapon_key4),
|
||||
view: :full
|
||||
),
|
||||
summons: Api::V1::CollectionSummonBlueprint.render_as_hash(
|
||||
@user.collection_summons.includes(:summon),
|
||||
view: :full
|
||||
),
|
||||
job_accessories: Api::V1::CollectionJobAccessoryBlueprint.render_as_hash(
|
||||
@user.collection_job_accessories.includes(job_accessory: :job)
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
render json: collection
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.find(params[:user_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "User not found" }, status: :not_found
|
||||
end
|
||||
|
||||
def check_collection_access
|
||||
unless @user.collection_viewable_by?(current_user)
|
||||
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionJobAccessoriesController < ApiController
|
||||
before_action :restrict_access
|
||||
before_action :set_collection_job_accessory, only: [:show, :destroy]
|
||||
|
||||
def index
|
||||
@collection_accessories = current_user.collection_job_accessories
|
||||
.includes(job_accessory: :job)
|
||||
|
||||
@collection_accessories = @collection_accessories.by_job(params[:job_id]) if params[:job_id]
|
||||
|
||||
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
|
||||
@collection_accessories,
|
||||
root: :collection_job_accessories
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
|
||||
@collection_job_accessory
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@collection_accessory = current_user.collection_job_accessories
|
||||
.build(collection_job_accessory_params)
|
||||
|
||||
if @collection_accessory.save
|
||||
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
|
||||
@collection_accessory
|
||||
), status: :created
|
||||
else
|
||||
# Check for duplicate job accessory error
|
||||
if @collection_accessory.errors[:job_accessory_id].any? { |e| e.include?('already exists') }
|
||||
raise CollectionErrors::DuplicateJobAccessory.new(@collection_accessory.job_accessory_id)
|
||||
end
|
||||
render_validation_error_response(@collection_accessory)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@collection_job_accessory.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection_job_accessory
|
||||
@collection_job_accessory = current_user.collection_job_accessories.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
raise CollectionErrors::CollectionItemNotFound.new('job accessory', params[:id])
|
||||
end
|
||||
|
||||
def collection_job_accessory_params
|
||||
params.require(:collection_job_accessory).permit(:job_accessory_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
75
app/controllers/api/v1/collection_summons_controller.rb
Normal file
75
app/controllers/api/v1/collection_summons_controller.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionSummonsController < ApiController
|
||||
before_action :restrict_access
|
||||
before_action :set_collection_summon, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@collection_summons = current_user.collection_summons
|
||||
.includes(:summon)
|
||||
|
||||
@collection_summons = @collection_summons.by_summon(params[:summon_id]) if params[:summon_id]
|
||||
@collection_summons = @collection_summons.by_element(params[:element]) if params[:element]
|
||||
@collection_summons = @collection_summons.by_rarity(params[:rarity]) if params[:rarity]
|
||||
|
||||
@collection_summons = @collection_summons.paginate(page: params[:page], per_page: params[:limit] || 50)
|
||||
|
||||
render json: Api::V1::CollectionSummonBlueprint.render(
|
||||
@collection_summons,
|
||||
root: :collection_summons,
|
||||
meta: pagination_meta(@collection_summons)
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
render json: Api::V1::CollectionSummonBlueprint.render(
|
||||
@collection_summon,
|
||||
view: :full
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@collection_summon = current_user.collection_summons.build(collection_summon_params)
|
||||
|
||||
if @collection_summon.save
|
||||
render json: Api::V1::CollectionSummonBlueprint.render(
|
||||
@collection_summon,
|
||||
view: :full
|
||||
), status: :created
|
||||
else
|
||||
render_validation_error_response(@collection_summon)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @collection_summon.update(collection_summon_params)
|
||||
render json: Api::V1::CollectionSummonBlueprint.render(
|
||||
@collection_summon,
|
||||
view: :full
|
||||
)
|
||||
else
|
||||
render_validation_error_response(@collection_summon)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@collection_summon.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection_summon
|
||||
@collection_summon = current_user.collection_summons.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
raise CollectionErrors::CollectionItemNotFound.new('summon', params[:id])
|
||||
end
|
||||
|
||||
def collection_summon_params
|
||||
params.require(:collection_summon).permit(
|
||||
:summon_id, :uncap_level, :transcendence_step
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
81
app/controllers/api/v1/collection_weapons_controller.rb
Normal file
81
app/controllers/api/v1/collection_weapons_controller.rb
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
module Api
|
||||
module V1
|
||||
class CollectionWeaponsController < ApiController
|
||||
before_action :restrict_access
|
||||
before_action :set_collection_weapon, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@collection_weapons = current_user.collection_weapons
|
||||
.includes(:weapon, :awakening,
|
||||
:weapon_key1, :weapon_key2,
|
||||
:weapon_key3, :weapon_key4)
|
||||
|
||||
@collection_weapons = @collection_weapons.by_weapon(params[:weapon_id]) if params[:weapon_id]
|
||||
@collection_weapons = @collection_weapons.by_element(params[:element]) if params[:element]
|
||||
@collection_weapons = @collection_weapons.by_rarity(params[:rarity]) if params[:rarity]
|
||||
|
||||
@collection_weapons = @collection_weapons.paginate(page: params[:page], per_page: params[:limit] || 50)
|
||||
|
||||
render json: Api::V1::CollectionWeaponBlueprint.render(
|
||||
@collection_weapons,
|
||||
root: :collection_weapons,
|
||||
meta: pagination_meta(@collection_weapons)
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
render json: Api::V1::CollectionWeaponBlueprint.render(
|
||||
@collection_weapon,
|
||||
view: :full
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@collection_weapon = current_user.collection_weapons.build(collection_weapon_params)
|
||||
|
||||
if @collection_weapon.save
|
||||
render json: Api::V1::CollectionWeaponBlueprint.render(
|
||||
@collection_weapon,
|
||||
view: :full
|
||||
), status: :created
|
||||
else
|
||||
render_validation_error_response(@collection_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @collection_weapon.update(collection_weapon_params)
|
||||
render json: Api::V1::CollectionWeaponBlueprint.render(
|
||||
@collection_weapon,
|
||||
view: :full
|
||||
)
|
||||
else
|
||||
render_validation_error_response(@collection_weapon)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@collection_weapon.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection_weapon
|
||||
@collection_weapon = current_user.collection_weapons.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
raise CollectionErrors::CollectionItemNotFound.new('weapon', params[:id])
|
||||
end
|
||||
|
||||
def collection_weapon_params
|
||||
params.require(:collection_weapon).permit(
|
||||
:weapon_id, :uncap_level, :transcendence_step,
|
||||
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
|
||||
:awakening_id, :awakening_level,
|
||||
:ax_modifier1, :ax_strength1, :ax_modifier2, :ax_strength2,
|
||||
:element
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -194,22 +194,11 @@ module Api
|
|||
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
|
||||
uncap_level = 4 if incoming.flb
|
||||
else
|
||||
uncap_level = 4
|
||||
uncap_level = 6 if incoming.ulb
|
||||
uncap_level = 5 if incoming.flb
|
||||
end
|
||||
|
||||
grid_character = GridCharacter.create!(
|
||||
party_id: @party.id,
|
||||
character_id: incoming.id,
|
||||
position: resolve_params[:position],
|
||||
uncap_level: uncap_level
|
||||
uncap_level: compute_max_uncap_level(incoming)
|
||||
)
|
||||
render json: GridCharacterBlueprint.render(grid_character,
|
||||
root: :grid_character,
|
||||
|
|
@ -248,7 +237,8 @@ module Api
|
|||
grid_character = GridCharacter.new(
|
||||
character_params.except(:rings, :awakening).merge(
|
||||
party_id: @party.id,
|
||||
character_id: @incoming_character.id
|
||||
character_id: @incoming_character.id,
|
||||
uncap_level: compute_max_uncap_level(@incoming_character)
|
||||
)
|
||||
)
|
||||
assign_transformed_attributes(grid_character, processed_params)
|
||||
|
|
@ -256,6 +246,24 @@ module Api
|
|||
grid_character
|
||||
end
|
||||
|
||||
##
|
||||
# Computes the maximum uncap level for a character based on its flags.
|
||||
#
|
||||
# Special characters (limited/seasonal) have a different uncap progression:
|
||||
# - Base: 3, FLB: 4, ULB: 5
|
||||
# Regular characters:
|
||||
# - Base: 4, FLB: 5, ULB: 6
|
||||
#
|
||||
# @param character [Character] the character to compute max uncap for.
|
||||
# @return [Integer] the maximum uncap level.
|
||||
def compute_max_uncap_level(character)
|
||||
if character.special
|
||||
character.ulb ? 5 : character.flb ? 4 : 3
|
||||
else
|
||||
character.ulb ? 6 : character.flb ? 5 : 4
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Assigns raw attributes from the original parameters to the grid character.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -30,10 +30,9 @@ module Api
|
|||
# @return [void]
|
||||
def create
|
||||
# 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.
|
||||
# Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
|
||||
grid_summon = build_grid_summon.tap do |gs|
|
||||
gs.uncap_level ||= max_uncap_level(gs.summon)
|
||||
gs.uncap_level = max_uncap_level(gs.summon)
|
||||
end
|
||||
|
||||
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ module Api
|
|||
grid_weapon = GridWeapon.new(
|
||||
weapon_params.merge(
|
||||
party_id: @party.id,
|
||||
weapon_id: @incoming_weapon.id
|
||||
weapon_id: @incoming_weapon.id,
|
||||
uncap_level: compute_default_uncap(@incoming_weapon)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ module Api
|
|||
end
|
||||
|
||||
def update_job_skills
|
||||
throw NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
|
||||
raise Api::V1::NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
|
||||
|
||||
# Determine which incoming keys contain new skills
|
||||
skill_keys = %w[skill1_id skill2_id skill3_id]
|
||||
|
|
@ -59,47 +59,47 @@ module Api
|
|||
|
||||
# If there are new skills, merge them with the existing skills
|
||||
unless new_skill_keys.empty?
|
||||
# Load skills ONCE upfront to avoid N+1 queries
|
||||
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
|
||||
new_skills_loaded = JobSkill.where(id: new_skill_ids).index_by(&:id)
|
||||
|
||||
# Validate all skills exist and are compatible
|
||||
new_skill_ids.each do |id|
|
||||
skill = new_skills_loaded[id]
|
||||
raise ActiveRecord::RecordNotFound.new("Couldn't find JobSkill") unless skill
|
||||
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job, skill)
|
||||
end
|
||||
|
||||
existing_skills = {
|
||||
1 => @party.skill1,
|
||||
2 => @party.skill2,
|
||||
3 => @party.skill3
|
||||
}
|
||||
|
||||
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
|
||||
new_skill_ids.map do |id|
|
||||
skill = JobSkill.find(id)
|
||||
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job,
|
||||
skill)
|
||||
end
|
||||
|
||||
positions = extract_positions_from_keys(new_skill_keys)
|
||||
new_skills = merge_skills_with_existing_skills(existing_skills, new_skill_ids, positions)
|
||||
# Pass loaded skills instead of IDs
|
||||
merged = merge_skills_with_loaded_skills(existing_skills, new_skill_ids.map { |id| new_skills_loaded[id] }, positions)
|
||||
|
||||
new_skill_ids = new_skills.each_with_object({}) do |(index, skill), memo|
|
||||
memo["skill#{index}_id"] = skill.id if skill
|
||||
skill_ids_hash = merged.each_with_object({}) do |(index, skill), memo|
|
||||
memo["skill#{index}_id"] = skill&.id
|
||||
end
|
||||
|
||||
@party.attributes = new_skill_ids
|
||||
@party.attributes = skill_ids_hash
|
||||
end
|
||||
|
||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
|
||||
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
|
||||
end
|
||||
|
||||
def destroy_job_skill
|
||||
position = job_params[:skill_position].to_i
|
||||
@party["skill#{position}_id"] = nil
|
||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save
|
||||
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_skills_with_existing_skills(
|
||||
existing_skills,
|
||||
new_skill_ids,
|
||||
positions
|
||||
)
|
||||
new_skills = new_skill_ids.map { |id| JobSkill.find(id) }
|
||||
|
||||
def merge_skills_with_loaded_skills(existing_skills, new_skills, positions)
|
||||
# new_skills is now an array of already-loaded JobSkill objects
|
||||
new_skills.each_with_index do |skill, index|
|
||||
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
|
||||
end
|
||||
|
|
@ -182,7 +182,8 @@ module Api
|
|||
end
|
||||
|
||||
def set
|
||||
@party = Party.where('id = ?', params[:id]).first
|
||||
@party = Party.find_by(shortcode: params[:id])
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
def job_params
|
||||
|
|
|
|||
|
|
@ -146,7 +146,14 @@ module Api
|
|||
def index
|
||||
query = build_filtered_query(build_common_base_query)
|
||||
@parties = query.paginate(page: params[:page], per_page: page_size)
|
||||
render_paginated_parties(@parties, page_size)
|
||||
|
||||
render json: Api::V1::PartyBlueprint.render(
|
||||
@parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: pagination_meta(@parties),
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
|
||||
# GET /api/v1/parties/favorites
|
||||
|
|
@ -159,7 +166,14 @@ module Api
|
|||
.distinct
|
||||
query = build_filtered_query(base_query)
|
||||
@parties = query.paginate(page: params[:page], per_page: page_size)
|
||||
render_paginated_parties(@parties, page_size)
|
||||
|
||||
render json: Api::V1::PartyBlueprint.render(
|
||||
@parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: pagination_meta(@parties),
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
|
||||
# Preview Management
|
||||
|
|
|
|||
|
|
@ -87,11 +87,7 @@ module Api
|
|||
render json: CharacterBlueprint.render(paginated,
|
||||
view: :dates,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages(count),
|
||||
per_page: search_page_size
|
||||
})
|
||||
meta: pagination_meta(paginated).merge(count: count))
|
||||
end
|
||||
|
||||
def weapons
|
||||
|
|
@ -126,11 +122,7 @@ module Api
|
|||
render json: WeaponBlueprint.render(paginated,
|
||||
view: :dates,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages(count),
|
||||
per_page: search_page_size
|
||||
})
|
||||
meta: pagination_meta(paginated).merge(count: count))
|
||||
end
|
||||
|
||||
def summons
|
||||
|
|
@ -160,11 +152,7 @@ module Api
|
|||
render json: SummonBlueprint.render(paginated,
|
||||
view: :dates,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages(count),
|
||||
per_page: search_page_size
|
||||
})
|
||||
meta: pagination_meta(paginated).merge(count: count))
|
||||
end
|
||||
|
||||
def job_skills
|
||||
|
|
@ -248,11 +236,7 @@ module Api
|
|||
|
||||
render json: JobSkillBlueprint.render(paginated,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages(count),
|
||||
per_page: search_page_size
|
||||
})
|
||||
meta: pagination_meta(paginated).merge(count: count))
|
||||
end
|
||||
|
||||
def guidebooks
|
||||
|
|
@ -268,20 +252,11 @@ module Api
|
|||
|
||||
render json: GuidebookBlueprint.render(paginated,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages(count),
|
||||
per_page: search_page_size
|
||||
})
|
||||
meta: pagination_meta(paginated).merge(count: count))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def total_pages(count)
|
||||
per_page = search_page_size
|
||||
(count.to_f / per_page).ceil
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def search_params
|
||||
return {} unless params[:search].present?
|
||||
|
|
|
|||
|
|
@ -31,21 +31,6 @@ module PartyQueryingConcern
|
|||
options: { apply_defaults: true }).build
|
||||
end
|
||||
|
||||
# Renders paginated parties using PartyBlueprint.
|
||||
def render_paginated_parties(parties, per_page = COLLECTION_PER_PAGE)
|
||||
render json: Api::V1::PartyBlueprint.render(
|
||||
parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: parties.total_entries,
|
||||
total_pages: parties.total_pages,
|
||||
per_page: per_page
|
||||
},
|
||||
current_user: current_user
|
||||
)
|
||||
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 }
|
||||
|
|
|
|||
48
app/errors/collection_errors.rb
Normal file
48
app/errors/collection_errors.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module CollectionErrors
|
||||
# Base class for all collection-related errors
|
||||
class CollectionError < StandardError
|
||||
attr_reader :http_status, :code
|
||||
|
||||
def initialize(message = nil, http_status: :unprocessable_entity, code: nil)
|
||||
super(message)
|
||||
@http_status = http_status
|
||||
@code = code || self.class.name.demodulize.underscore
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
error: {
|
||||
type: self.class.name.demodulize,
|
||||
message: message,
|
||||
code: code
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Raised when a collection item cannot be found
|
||||
class CollectionItemNotFound < CollectionError
|
||||
def initialize(item_type = 'item', item_id = nil)
|
||||
message = item_id ? "Collection #{item_type} with ID #{item_id} not found" : "Collection #{item_type} not found"
|
||||
super(message, http_status: :not_found)
|
||||
end
|
||||
end
|
||||
|
||||
# Raised when trying to add a duplicate character to collection
|
||||
class DuplicateCharacter < CollectionError
|
||||
def initialize(character_id = nil)
|
||||
message = character_id ? "Character #{character_id} already exists in your collection" : "Character already exists in your collection"
|
||||
super(message, http_status: :conflict)
|
||||
end
|
||||
end
|
||||
|
||||
# Raised when trying to add a duplicate job accessory to collection
|
||||
class DuplicateJobAccessory < CollectionError
|
||||
def initialize(accessory_id = nil)
|
||||
message = accessory_id ? "Job accessory #{accessory_id} already exists in your collection" : "Job accessory already exists in your collection"
|
||||
super(message, http_status: :conflict)
|
||||
end
|
||||
end
|
||||
end
|
||||
57
app/models/collection_character.rb
Normal file
57
app/models/collection_character.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
class CollectionCharacter < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :character
|
||||
belongs_to :awakening, optional: true
|
||||
|
||||
validates :character_id, uniqueness: { scope: :user_id,
|
||||
message: "already exists in your collection" }
|
||||
validates :uncap_level, inclusion: { in: 0..5 }
|
||||
validates :transcendence_step, inclusion: { in: 0..10 }
|
||||
validates :awakening_level, inclusion: { in: 1..10 }
|
||||
|
||||
validate :validate_rings
|
||||
validate :validate_awakening_compatibility
|
||||
validate :validate_awakening_level
|
||||
validate :validate_transcendence_requirements
|
||||
|
||||
scope :by_element, ->(element) { joins(:character).where(characters: { element: element }) }
|
||||
scope :by_rarity, ->(rarity) { joins(:character).where(characters: { rarity: rarity }) }
|
||||
scope :transcended, -> { where('transcendence_step > 0') }
|
||||
scope :with_awakening, -> { where.not(awakening_id: nil) }
|
||||
|
||||
def blueprint
|
||||
Api::V1::CollectionCharacterBlueprint
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_rings
|
||||
[ring1, ring2, ring3, ring4, earring].each_with_index do |ring, index|
|
||||
next unless ring['modifier'].present? || ring['strength'].present?
|
||||
|
||||
if ring['modifier'].blank? || ring['strength'].blank?
|
||||
errors.add(:base, "Ring #{index + 1} must have both modifier and strength")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_awakening_compatibility
|
||||
return unless awakening.present?
|
||||
|
||||
unless awakening.object_type == 'Character'
|
||||
errors.add(:awakening, "must be a character awakening")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_awakening_level
|
||||
if awakening_level.present? && awakening_level > 1 && awakening_id.blank?
|
||||
errors.add(:awakening_level, "cannot be set without an awakening")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_transcendence_requirements
|
||||
if transcendence_step.present? && transcendence_step > 0 && uncap_level < 5
|
||||
errors.add(:transcendence_step, "requires uncap level 5 (current: #{uncap_level})")
|
||||
end
|
||||
end
|
||||
end
|
||||
14
app/models/collection_job_accessory.rb
Normal file
14
app/models/collection_job_accessory.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class CollectionJobAccessory < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :job_accessory
|
||||
|
||||
validates :job_accessory_id, uniqueness: { scope: :user_id,
|
||||
message: "already exists in your collection" }
|
||||
|
||||
scope :by_job, ->(job_id) { joins(:job_accessory).where(job_accessories: { job_id: job_id }) }
|
||||
scope :by_job_accessory, ->(job_accessory_id) { where(job_accessory_id: job_accessory_id) }
|
||||
|
||||
def blueprint
|
||||
Api::V1::CollectionJobAccessoryBlueprint
|
||||
end
|
||||
end
|
||||
34
app/models/collection_summon.rb
Normal file
34
app/models/collection_summon.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
class CollectionSummon < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :summon
|
||||
|
||||
validates :uncap_level, inclusion: { in: 0..5 }
|
||||
validates :transcendence_step, inclusion: { in: 0..10 }
|
||||
|
||||
validate :validate_transcendence_requirements
|
||||
|
||||
scope :by_summon, ->(summon_id) { where(summon_id: summon_id) }
|
||||
scope :by_element, ->(element) { joins(:summon).where(summons: { element: element }) }
|
||||
scope :by_rarity, ->(rarity) { joins(:summon).where(summons: { rarity: rarity }) }
|
||||
scope :transcended, -> { where('transcendence_step > 0') }
|
||||
scope :max_uncapped, -> { where(uncap_level: 5) }
|
||||
|
||||
def blueprint
|
||||
Api::V1::CollectionSummonBlueprint
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_transcendence_requirements
|
||||
return unless transcendence_step.present? && transcendence_step > 0
|
||||
|
||||
if uncap_level < 5
|
||||
errors.add(:transcendence_step, "requires uncap level 5 (current: #{uncap_level})")
|
||||
end
|
||||
|
||||
# Some summons might not support transcendence
|
||||
if summon.present? && !summon.transcendence
|
||||
errors.add(:transcendence_step, "not available for this summon")
|
||||
end
|
||||
end
|
||||
end
|
||||
109
app/models/collection_weapon.rb
Normal file
109
app/models/collection_weapon.rb
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
class CollectionWeapon < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :weapon
|
||||
belongs_to :awakening, optional: true
|
||||
|
||||
belongs_to :weapon_key1, class_name: 'WeaponKey', optional: true
|
||||
belongs_to :weapon_key2, class_name: 'WeaponKey', optional: true
|
||||
belongs_to :weapon_key3, class_name: 'WeaponKey', optional: true
|
||||
belongs_to :weapon_key4, class_name: 'WeaponKey', optional: true
|
||||
|
||||
validates :uncap_level, inclusion: { in: 0..5 }
|
||||
validates :transcendence_step, inclusion: { in: 0..10 }
|
||||
validates :awakening_level, inclusion: { in: 1..10 }
|
||||
|
||||
validate :validate_weapon_keys
|
||||
validate :validate_ax_skills
|
||||
validate :validate_element_change
|
||||
validate :validate_awakening_compatibility
|
||||
validate :validate_awakening_level
|
||||
validate :validate_transcendence_requirements
|
||||
|
||||
scope :by_weapon, ->(weapon_id) { where(weapon_id: weapon_id) }
|
||||
scope :by_series, ->(series) { joins(:weapon).where(weapons: { series: series }) }
|
||||
scope :with_keys, -> { where.not(weapon_key1_id: nil) }
|
||||
scope :with_ax, -> { where.not(ax_modifier1: nil) }
|
||||
scope :by_element, ->(element) { joins(:weapon).where(weapons: { element: element }) }
|
||||
scope :by_rarity, ->(rarity) { joins(:weapon).where(weapons: { rarity: rarity }) }
|
||||
scope :transcended, -> { where('transcendence_step > 0') }
|
||||
scope :with_awakening, -> { where.not(awakening_id: nil) }
|
||||
|
||||
def blueprint
|
||||
Api::V1::CollectionWeaponBlueprint
|
||||
end
|
||||
|
||||
def weapon_keys
|
||||
[weapon_key1, weapon_key2, weapon_key3, weapon_key4].compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_weapon_keys
|
||||
return unless weapon.present?
|
||||
|
||||
# Validate weapon_key4 is only on Opus/Draconic weapons
|
||||
if weapon_key4.present? && ![3, 27].include?(weapon.series)
|
||||
errors.add(:weapon_key4, "can only be set on Opus or Draconic weapons")
|
||||
end
|
||||
|
||||
weapon_keys.each do |key|
|
||||
unless weapon.compatible_with_key?(key)
|
||||
errors.add(:weapon_keys, "#{key.name_en} is not compatible with this weapon")
|
||||
end
|
||||
end
|
||||
|
||||
# Check for duplicate keys
|
||||
key_ids = [weapon_key1_id, weapon_key2_id, weapon_key3_id, weapon_key4_id].compact
|
||||
if key_ids.length != key_ids.uniq.length
|
||||
errors.add(:weapon_keys, "cannot have duplicate keys")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_ax_skills
|
||||
# Check for incomplete AX skills regardless of weapon.ax
|
||||
if (ax_modifier1.present? && ax_strength1.blank?) ||
|
||||
(ax_modifier1.blank? && ax_strength1.present?)
|
||||
errors.add(:base, "AX skill 1 must have both modifier and strength")
|
||||
end
|
||||
|
||||
if (ax_modifier2.present? && ax_strength2.blank?) ||
|
||||
(ax_modifier2.blank? && ax_strength2.present?)
|
||||
errors.add(:base, "AX skill 2 must have both modifier and strength")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_element_change
|
||||
return unless element.present? && weapon.present?
|
||||
|
||||
unless Weapon.element_changeable?(weapon.series)
|
||||
errors.add(:element, "can only be set on element-changeable weapons")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_awakening_compatibility
|
||||
return unless awakening.present?
|
||||
|
||||
unless awakening.object_type == 'Weapon'
|
||||
errors.add(:awakening, "must be a weapon awakening")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_awakening_level
|
||||
if awakening_level.present? && awakening_level > 1 && awakening_id.blank?
|
||||
errors.add(:awakening_level, "cannot be set without an awakening")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_transcendence_requirements
|
||||
return unless transcendence_step.present? && transcendence_step > 0
|
||||
|
||||
if uncap_level < 5
|
||||
errors.add(:transcendence_step, "requires uncap level 5 (current: #{uncap_level})")
|
||||
end
|
||||
|
||||
# Some weapons might not support transcendence
|
||||
if weapon.present? && !weapon.transcendence
|
||||
errors.add(:transcendence_step, "not available for this weapon") if transcendence_step > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,13 @@ class User < ApplicationRecord
|
|||
##### ActiveRecord Associations
|
||||
has_many :parties, dependent: :destroy
|
||||
has_many :favorites, dependent: :destroy
|
||||
has_many :collection_characters, dependent: :destroy
|
||||
has_many :collection_weapons, dependent: :destroy
|
||||
has_many :collection_summons, dependent: :destroy
|
||||
has_many :collection_job_accessories, dependent: :destroy
|
||||
|
||||
# Note: The crew association will be added when crews feature is implemented
|
||||
# belongs_to :crew, optional: true
|
||||
|
||||
##### ActiveRecord Validations
|
||||
validates :username,
|
||||
|
|
@ -39,6 +46,15 @@ class User < ApplicationRecord
|
|||
##### ActiveModel Security
|
||||
has_secure_password
|
||||
|
||||
##### Enums
|
||||
# Enum for collection privacy levels
|
||||
enum :collection_privacy, {
|
||||
everyone: 0,
|
||||
crew_only: 1,
|
||||
private_collection: 2
|
||||
}, prefix: true
|
||||
|
||||
##### Instance Methods
|
||||
def favorite_parties
|
||||
favorites.map(&:party)
|
||||
end
|
||||
|
|
@ -50,4 +66,30 @@ class User < ApplicationRecord
|
|||
def blueprint
|
||||
UserBlueprint
|
||||
end
|
||||
end
|
||||
|
||||
# Check if collection is viewable by another user
|
||||
def collection_viewable_by?(viewer)
|
||||
return true if self == viewer # Owners can always view their own collection
|
||||
|
||||
case collection_privacy
|
||||
when 'everyone'
|
||||
true
|
||||
when 'crew_only'
|
||||
# Will be implemented when crew feature is added:
|
||||
# viewer.present? && crew.present? && viewer.crew_id == crew_id
|
||||
false # For now, crew_only acts like private until crews are implemented
|
||||
when 'private_collection'
|
||||
false
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Helper method to check if user is in same crew (placeholder for future)
|
||||
def in_same_crew_as?(other_user)
|
||||
# Will be implemented when crew feature is added:
|
||||
# return false unless other_user.present?
|
||||
# crew.present? && other_user.crew_id == crew_id
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
@ -4,11 +4,11 @@ class AwsService
|
|||
class ConfigurationError < StandardError; end
|
||||
|
||||
def initialize
|
||||
Rails.logger.info "Environment: #{Rails.env}"
|
||||
Rails.logger.debug "Environment: #{Rails.env}"
|
||||
|
||||
# Try different methods of getting credentials
|
||||
creds = get_credentials
|
||||
Rails.logger.info "Credentials source: #{creds[:source]}"
|
||||
Rails.logger.debug "Credentials source: #{creds[:source]}"
|
||||
|
||||
@s3_client = Aws::S3::Client.new(
|
||||
region: creds[:region],
|
||||
|
|
@ -44,14 +44,14 @@ class AwsService
|
|||
# Try Rails credentials first
|
||||
rails_creds = Rails.application.credentials.dig(:aws)
|
||||
if rails_creds&.dig(:access_key_id)
|
||||
Rails.logger.info "Using Rails credentials"
|
||||
Rails.logger.debug "Using Rails credentials"
|
||||
return rails_creds.merge(source: 'rails_credentials')
|
||||
end
|
||||
|
||||
# Try string keys
|
||||
rails_creds = Rails.application.credentials.dig('aws')
|
||||
if rails_creds&.dig('access_key_id')
|
||||
Rails.logger.info "Using Rails credentials (string keys)"
|
||||
Rails.logger.debug "Using Rails credentials (string keys)"
|
||||
return {
|
||||
region: rails_creds['region'],
|
||||
access_key_id: rails_creds['access_key_id'],
|
||||
|
|
@ -63,7 +63,7 @@ class AwsService
|
|||
|
||||
# Try environment variables
|
||||
if ENV['AWS_ACCESS_KEY_ID']
|
||||
Rails.logger.info "Using environment variables"
|
||||
Rails.logger.debug "Using environment variables"
|
||||
return {
|
||||
region: ENV['AWS_REGION'],
|
||||
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
|
||||
|
|
@ -75,7 +75,7 @@ class AwsService
|
|||
|
||||
# Try alternate environment variable names
|
||||
if ENV['RAILS_AWS_ACCESS_KEY_ID']
|
||||
Rails.logger.info "Using Rails-prefixed environment variables"
|
||||
Rails.logger.debug "Using Rails-prefixed environment variables"
|
||||
return {
|
||||
region: ENV['RAILS_AWS_REGION'],
|
||||
access_key_id: ENV['RAILS_AWS_ACCESS_KEY_ID'],
|
||||
|
|
|
|||
|
|
@ -87,6 +87,18 @@ Rails.application.routes.draw do
|
|||
post 'parties/:id/grid_update', to: 'parties#grid_update'
|
||||
|
||||
delete 'favorites', to: 'favorites#destroy'
|
||||
|
||||
# User collection viewing (respects privacy settings)
|
||||
get 'users/:user_id/collection', to: 'collection#show'
|
||||
|
||||
# Collection management for current user
|
||||
namespace :collection do
|
||||
resources :characters, controller: '/api/v1/collection_characters'
|
||||
resources :weapons, controller: '/api/v1/collection_weapons'
|
||||
resources :summons, controller: '/api/v1/collection_summons'
|
||||
resources :job_accessories, controller: '/api/v1/collection_job_accessories',
|
||||
only: [:index, :show, :create, :destroy]
|
||||
end
|
||||
end
|
||||
|
||||
if Rails.env.development?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
class AddCollectionPrivacyToUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# Privacy levels: 0 = public, 1 = crew_only, 2 = private
|
||||
add_column :users, :collection_privacy, :integer, default: 0, null: false
|
||||
add_index :users, :collection_privacy
|
||||
end
|
||||
end
|
||||
23
db/migrate/20250928120001_create_collection_characters.rb
Normal file
23
db/migrate/20250928120001_create_collection_characters.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
class CreateCollectionCharacters < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :collection_characters, id: :uuid do |t|
|
||||
t.references :user, type: :uuid, null: false, foreign_key: true
|
||||
t.references :character, type: :uuid, null: false, foreign_key: true
|
||||
t.integer :uncap_level, default: 0, null: false
|
||||
t.integer :transcendence_step, default: 0, null: false
|
||||
t.boolean :perpetuity, default: false, null: false
|
||||
t.references :awakening, type: :uuid, foreign_key: true
|
||||
t.integer :awakening_level, default: 1
|
||||
|
||||
t.jsonb :ring1, default: { modifier: nil, strength: nil }, null: false
|
||||
t.jsonb :ring2, default: { modifier: nil, strength: nil }, null: false
|
||||
t.jsonb :ring3, default: { modifier: nil, strength: nil }, null: false
|
||||
t.jsonb :ring4, default: { modifier: nil, strength: nil }, null: false
|
||||
t.jsonb :earring, default: { modifier: nil, strength: nil }, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :collection_characters, [:user_id, :character_id], unique: true
|
||||
end
|
||||
end
|
||||
28
db/migrate/20250928120002_create_collection_weapons.rb
Normal file
28
db/migrate/20250928120002_create_collection_weapons.rb
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
class CreateCollectionWeapons < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :collection_weapons, id: :uuid do |t|
|
||||
t.references :user, type: :uuid, null: false, foreign_key: true
|
||||
t.references :weapon, type: :uuid, null: false, foreign_key: true
|
||||
t.integer :uncap_level, default: 0, null: false
|
||||
t.integer :transcendence_step, default: 0
|
||||
|
||||
t.references :weapon_key1, type: :uuid, foreign_key: { to_table: :weapon_keys }
|
||||
t.references :weapon_key2, type: :uuid, foreign_key: { to_table: :weapon_keys }
|
||||
t.references :weapon_key3, type: :uuid, foreign_key: { to_table: :weapon_keys }
|
||||
t.references :weapon_key4, type: :uuid, foreign_key: { to_table: :weapon_keys }
|
||||
|
||||
t.references :awakening, type: :uuid, foreign_key: true
|
||||
t.integer :awakening_level, default: 1, null: false
|
||||
|
||||
t.integer :ax_modifier1
|
||||
t.float :ax_strength1
|
||||
t.integer :ax_modifier2
|
||||
t.float :ax_strength2
|
||||
t.integer :element
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :collection_weapons, [:user_id, :weapon_id], unique: true
|
||||
end
|
||||
end
|
||||
14
db/migrate/20250928120003_create_collection_summons.rb
Normal file
14
db/migrate/20250928120003_create_collection_summons.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class CreateCollectionSummons < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :collection_summons, id: :uuid do |t|
|
||||
t.references :user, type: :uuid, null: false, foreign_key: true
|
||||
t.references :summon, type: :uuid, null: false, foreign_key: true
|
||||
t.integer :uncap_level, default: 0, null: false
|
||||
t.integer :transcendence_step, default: 0, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :collection_summons, [:user_id, :summon_id], unique: true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
class CreateCollectionJobAccessories < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :collection_job_accessories, id: :uuid do |t|
|
||||
t.references :user, type: :uuid, null: false, foreign_key: true
|
||||
t.references :job_accessory, type: :uuid, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :collection_job_accessories, [:user_id, :job_accessory_id],
|
||||
unique: true, name: 'idx_collection_job_acc_user_accessory'
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
class RemoveUniqueConstraintFromWeaponsAndSummons < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# Remove unique indexes for collection_weapons
|
||||
remove_index :collection_weapons, [:user_id, :weapon_id]
|
||||
add_index :collection_weapons, [:user_id, :weapon_id]
|
||||
|
||||
# Remove unique indexes for collection_summons
|
||||
remove_index :collection_summons, [:user_id, :summon_id]
|
||||
add_index :collection_summons, [:user_id, :summon_id]
|
||||
end
|
||||
end
|
||||
89
db/schema.rb
89
db/schema.rb
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_28_120005) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
|
@ -105,6 +105,77 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
|
|||
t.index ["skill_id"], name: "index_charge_attacks_on_skill_id"
|
||||
end
|
||||
|
||||
create_table "collection_characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id", null: false
|
||||
t.uuid "character_id", null: false
|
||||
t.integer "uncap_level", default: 0, null: false
|
||||
t.integer "transcendence_step", default: 0, null: false
|
||||
t.boolean "perpetuity", default: false, null: false
|
||||
t.uuid "awakening_id"
|
||||
t.integer "awakening_level", default: 1
|
||||
t.jsonb "ring1", default: {"modifier"=>nil, "strength"=>nil}, null: false
|
||||
t.jsonb "ring2", default: {"modifier"=>nil, "strength"=>nil}, null: false
|
||||
t.jsonb "ring3", default: {"modifier"=>nil, "strength"=>nil}, null: false
|
||||
t.jsonb "ring4", default: {"modifier"=>nil, "strength"=>nil}, null: false
|
||||
t.jsonb "earring", default: {"modifier"=>nil, "strength"=>nil}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["awakening_id"], name: "index_collection_characters_on_awakening_id"
|
||||
t.index ["character_id"], name: "index_collection_characters_on_character_id"
|
||||
t.index ["user_id", "character_id"], name: "index_collection_characters_on_user_id_and_character_id", unique: true
|
||||
t.index ["user_id"], name: "index_collection_characters_on_user_id"
|
||||
end
|
||||
|
||||
create_table "collection_job_accessories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id", null: false
|
||||
t.uuid "job_accessory_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["job_accessory_id"], name: "index_collection_job_accessories_on_job_accessory_id"
|
||||
t.index ["user_id", "job_accessory_id"], name: "idx_collection_job_acc_user_accessory", unique: true
|
||||
t.index ["user_id"], name: "index_collection_job_accessories_on_user_id"
|
||||
end
|
||||
|
||||
create_table "collection_summons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id", null: false
|
||||
t.uuid "summon_id", null: false
|
||||
t.integer "uncap_level", default: 0, null: false
|
||||
t.integer "transcendence_step", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["summon_id"], name: "index_collection_summons_on_summon_id"
|
||||
t.index ["user_id", "summon_id"], name: "index_collection_summons_on_user_id_and_summon_id"
|
||||
t.index ["user_id"], name: "index_collection_summons_on_user_id"
|
||||
end
|
||||
|
||||
create_table "collection_weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id", null: false
|
||||
t.uuid "weapon_id", null: false
|
||||
t.integer "uncap_level", default: 0, null: false
|
||||
t.integer "transcendence_step", default: 0
|
||||
t.uuid "weapon_key1_id"
|
||||
t.uuid "weapon_key2_id"
|
||||
t.uuid "weapon_key3_id"
|
||||
t.uuid "weapon_key4_id"
|
||||
t.uuid "awakening_id"
|
||||
t.integer "awakening_level", default: 1, null: false
|
||||
t.integer "ax_modifier1"
|
||||
t.float "ax_strength1"
|
||||
t.integer "ax_modifier2"
|
||||
t.float "ax_strength2"
|
||||
t.integer "element"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["awakening_id"], name: "index_collection_weapons_on_awakening_id"
|
||||
t.index ["user_id", "weapon_id"], name: "index_collection_weapons_on_user_id_and_weapon_id"
|
||||
t.index ["user_id"], name: "index_collection_weapons_on_user_id"
|
||||
t.index ["weapon_id"], name: "index_collection_weapons_on_weapon_id"
|
||||
t.index ["weapon_key1_id"], name: "index_collection_weapons_on_weapon_key1_id"
|
||||
t.index ["weapon_key2_id"], name: "index_collection_weapons_on_weapon_key2_id"
|
||||
t.index ["weapon_key3_id"], name: "index_collection_weapons_on_weapon_key3_id"
|
||||
t.index ["weapon_key4_id"], name: "index_collection_weapons_on_weapon_key4_id"
|
||||
end
|
||||
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
|
|
@ -569,6 +640,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
|
|||
t.integer "gender", default: 0, null: false
|
||||
t.string "theme", default: "system", null: false
|
||||
t.integer "role", default: 1, null: false
|
||||
t.integer "collection_privacy", default: 0, null: false
|
||||
t.index ["collection_privacy"], name: "index_users_on_collection_privacy"
|
||||
end
|
||||
|
||||
create_table "weapon_awakenings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
|
@ -655,6 +728,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
|
|||
add_foreign_key "character_skills", "skills", column: "alt_skill_id"
|
||||
add_foreign_key "charge_attacks", "skills"
|
||||
add_foreign_key "charge_attacks", "skills", column: "alt_skill_id"
|
||||
add_foreign_key "collection_characters", "awakenings"
|
||||
add_foreign_key "collection_characters", "characters"
|
||||
add_foreign_key "collection_characters", "users"
|
||||
add_foreign_key "collection_job_accessories", "job_accessories"
|
||||
add_foreign_key "collection_job_accessories", "users"
|
||||
add_foreign_key "collection_summons", "summons"
|
||||
add_foreign_key "collection_summons", "users"
|
||||
add_foreign_key "collection_weapons", "awakenings"
|
||||
add_foreign_key "collection_weapons", "users"
|
||||
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key1_id"
|
||||
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key2_id"
|
||||
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key3_id"
|
||||
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key4_id"
|
||||
add_foreign_key "collection_weapons", "weapons"
|
||||
add_foreign_key "effects", "effects", column: "effect_family_id"
|
||||
add_foreign_key "favorites", "parties"
|
||||
add_foreign_key "favorites", "users"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ def load_csv_for(model_class, csv_filename, unique_key = :granblue_id, use_id: f
|
|||
# Remove the :id attribute unless we want to preserve it (for fixed canonical IDs).
|
||||
attrs.except!(:id) unless use_id
|
||||
|
||||
# Skip records with missing associations (for test data)
|
||||
if model_class == WeaponAwakening
|
||||
next unless Weapon.exists?(attrs[:weapon_id]) && Awakening.exists?(attrs[:awakening_id])
|
||||
end
|
||||
|
||||
# Find or create the record based on the unique key.
|
||||
record = model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
|
||||
# Assign all attributes except the unique_key.
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def seed_weapons
|
|||
w.max_atk = row['max_hp']
|
||||
w.max_atk_flb = row['max_hp_flb']
|
||||
w.max_atk_ulb = row['max_hp_ulb']
|
||||
w.recruits_id = row['recruits_id']
|
||||
w.recruits = row['recruits']
|
||||
w.save
|
||||
end
|
||||
|
||||
|
|
|
|||
187
docs/README.md
Normal file
187
docs/README.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Hensei API Utilities Documentation
|
||||
|
||||
This directory contains documentation for the various utilities and tools available in the hensei-api `/lib` folder. These utilities handle data import/export, image downloading, wiki parsing, and data transformation for Granblue Fantasy game assets.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The most common tasks you'll perform:
|
||||
|
||||
```bash
|
||||
# Download images for a specific character
|
||||
rake granblue:export:character_images[3040001000]
|
||||
|
||||
# Import data from CSV files
|
||||
rake granblue:import_data
|
||||
|
||||
# Fetch wiki data for all characters
|
||||
rake granblue:fetch_wiki_data type=Character
|
||||
|
||||
# Generate party preview images
|
||||
rake previews:generate_all
|
||||
```
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
### Core Utilities
|
||||
|
||||
- **[Downloaders](./downloaders.md)** - Image downloading system for game assets
|
||||
- Character, Weapon, Summon, and Job image downloaders
|
||||
- Support for multiple image sizes and variants
|
||||
- S3 and local storage options
|
||||
|
||||
- **[Importers](./importers.md)** - CSV data import system
|
||||
- Bulk import of character, weapon, and summon data
|
||||
- Test mode for validation
|
||||
- Update tracking and error handling
|
||||
|
||||
- **[Parsers](./parsers.md)** - Wiki data extraction and parsing
|
||||
- Fetch and parse data from Granblue Fantasy Wiki
|
||||
- Character, weapon, and summon information extraction
|
||||
- Skill parsing and data normalization
|
||||
|
||||
- **[Transformers](./transformers.md)** - Data transformation utilities
|
||||
- Convert between different data formats
|
||||
- Element mapping and normalization
|
||||
- Error handling and validation
|
||||
|
||||
### Task Reference
|
||||
|
||||
- **[Rake Tasks](./rake-tasks.md)** - Complete guide to all available rake tasks
|
||||
- Data management tasks
|
||||
- Image download tasks
|
||||
- Wiki synchronization
|
||||
- Preview generation
|
||||
- Database utilities
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
lib/
|
||||
├── granblue/
|
||||
│ ├── downloaders/ # Image download utilities
|
||||
│ │ ├── base_downloader.rb
|
||||
│ │ ├── character_downloader.rb
|
||||
│ │ ├── weapon_downloader.rb
|
||||
│ │ ├── summon_downloader.rb
|
||||
│ │ └── job_downloader.rb
|
||||
│ │
|
||||
│ ├── importers/ # CSV import utilities
|
||||
│ │ ├── base_importer.rb
|
||||
│ │ ├── character_importer.rb
|
||||
│ │ ├── weapon_importer.rb
|
||||
│ │ └── summon_importer.rb
|
||||
│ │
|
||||
│ ├── parsers/ # Wiki parsing utilities
|
||||
│ │ ├── base_parser.rb
|
||||
│ │ ├── character_parser.rb
|
||||
│ │ ├── weapon_parser.rb
|
||||
│ │ └── summon_parser.rb
|
||||
│ │
|
||||
│ └── transformers/ # Data transformation
|
||||
│ ├── base_transformer.rb
|
||||
│ ├── character_transformer.rb
|
||||
│ ├── weapon_transformer.rb
|
||||
│ └── summon_transformer.rb
|
||||
│
|
||||
└── tasks/ # Rake tasks
|
||||
├── download_all_images.rake
|
||||
├── export_*.rake
|
||||
├── fetch_wiki.rake
|
||||
├── import_data.rake
|
||||
└── previews.rake
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Adding New Game Content
|
||||
|
||||
1. **Fetch wiki data** for the new content:
|
||||
```bash
|
||||
rake granblue:fetch_wiki_data type=Character id=3040001000
|
||||
```
|
||||
|
||||
2. **Import CSV data** if available:
|
||||
```bash
|
||||
# Place CSV in db/seed/updates/
|
||||
rake granblue:import_data
|
||||
```
|
||||
|
||||
3. **Download images** for the content:
|
||||
```bash
|
||||
rake granblue:export:character_images[3040001000]
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
For processing multiple items:
|
||||
|
||||
```bash
|
||||
# Download all character images with parallel processing
|
||||
rake granblue:download_all_images[character,4]
|
||||
|
||||
# Download only specific image sizes
|
||||
rake granblue:download_all_images[weapon,4,grid]
|
||||
|
||||
# Test mode - simulate without actual downloads
|
||||
rake granblue:export:weapon_images[,true,true,both]
|
||||
```
|
||||
|
||||
### Storage Options
|
||||
|
||||
All downloaders support three storage modes:
|
||||
|
||||
- `local` - Save to local filesystem only
|
||||
- `s3` - Upload to S3 only
|
||||
- `both` - Save locally and upload to S3 (default)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
rake granblue:export:summon_images[2040001000,false,true,s3]
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Common environment variables used by utilities:
|
||||
|
||||
- `TEST=true` - Run in test mode (no actual changes)
|
||||
- `VERBOSE=true` - Enable detailed logging
|
||||
- `FORCE=true` - Force re-download/re-process even if data exists
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **404 errors during download**: The asset may not exist on the game server yet
|
||||
2. **Wiki parse errors**: The wiki page format may have changed
|
||||
3. **Import validation errors**: Check CSV format matches expected schema
|
||||
4. **S3 upload failures**: Verify AWS credentials and bucket permissions
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Most utilities support debug/verbose mode:
|
||||
|
||||
```bash
|
||||
# Verbose output for debugging
|
||||
rake granblue:export:character_images[3040001000,false,true]
|
||||
|
||||
# Test mode to simulate operations
|
||||
rake granblue:import_data TEST=true VERBOSE=true
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new utilities:
|
||||
|
||||
1. Extend the appropriate base class (`BaseDownloader`, `BaseImporter`, etc.)
|
||||
2. Follow the existing naming conventions
|
||||
3. Add corresponding rake task in `lib/tasks/`
|
||||
4. Update this documentation
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about these utilities, check:
|
||||
|
||||
1. The specific utility's documentation
|
||||
2. The rake task help: `rake -T granblue`
|
||||
3. Rails logs for detailed error messages
|
||||
4. AWS S3 logs for storage issues
|
||||
368
docs/downloaders.md
Normal file
368
docs/downloaders.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# Image Downloaders Documentation
|
||||
|
||||
The downloader system provides a flexible framework for downloading game asset images from Granblue Fantasy servers. It supports multiple image sizes, variants, and storage backends (local filesystem and AWS S3).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Base Downloader
|
||||
|
||||
All downloaders inherit from `BaseDownloader` which provides:
|
||||
- Storage management (local, S3, or both)
|
||||
- Download retry logic
|
||||
- 404 error handling
|
||||
- Verbose logging support
|
||||
- Test mode for dry runs
|
||||
|
||||
### Available Downloaders
|
||||
|
||||
#### CharacterDownloader
|
||||
Downloads character portrait images in multiple variants.
|
||||
|
||||
**Image Sizes:**
|
||||
- `main` - Full character art (f/)
|
||||
- `grid` - Medium portrait (m/)
|
||||
- `square` - Small square icon (s/)
|
||||
- `detail` - Detailed view (detail/)
|
||||
|
||||
**Variants:**
|
||||
- `_01` - Base art
|
||||
- `_02` - First uncap art
|
||||
- `_03` - FLB (5★) art (if available)
|
||||
- `_04` - ULB (6★) art (if available)
|
||||
|
||||
**Example:**
|
||||
```ruby
|
||||
downloader = Granblue::Downloaders::CharacterDownloader.new(
|
||||
"3040001000",
|
||||
storage: :both,
|
||||
verbose: true
|
||||
)
|
||||
downloader.download # Downloads all sizes and variants
|
||||
downloader.download("grid") # Downloads only grid size
|
||||
```
|
||||
|
||||
#### WeaponDownloader
|
||||
Downloads weapon images with elemental variations.
|
||||
|
||||
**Image Sizes:**
|
||||
- `main` - Full weapon art (ls/)
|
||||
- `grid` - Grid view (m/)
|
||||
- `square` - Small icon (s/)
|
||||
|
||||
**Example:**
|
||||
```ruby
|
||||
downloader = Granblue::Downloaders::WeaponDownloader.new(
|
||||
"1040001000",
|
||||
storage: :s3,
|
||||
verbose: true
|
||||
)
|
||||
downloader.download
|
||||
```
|
||||
|
||||
#### SummonDownloader
|
||||
Downloads summon images including uncap variants.
|
||||
|
||||
**Image Sizes:**
|
||||
- `main` - Full summon art (b/)
|
||||
- `grid` - Grid view (m/)
|
||||
- `square` - Small icon (s/)
|
||||
|
||||
**Variants:**
|
||||
- Base art and uncap variations based on summon's max_level
|
||||
|
||||
**Example:**
|
||||
```ruby
|
||||
downloader = Granblue::Downloaders::SummonDownloader.new(
|
||||
"2040001000",
|
||||
storage: :local,
|
||||
verbose: true
|
||||
)
|
||||
downloader.download
|
||||
```
|
||||
|
||||
#### JobDownloader
|
||||
Downloads job class images with gender variants.
|
||||
|
||||
**Image Sizes:**
|
||||
- `wide` - Wide format portrait
|
||||
- `zoom` - Close-up portrait (1138x1138)
|
||||
|
||||
**Variants:**
|
||||
- `_a` - Male variant
|
||||
- `_b` - Female variant
|
||||
|
||||
**URLs:**
|
||||
- Wide: `https://prd-game-a3-granbluefantasy.akamaized.net/assets_en/img/sp/assets/leader/m/{id}_01.jpg`
|
||||
- Zoom: `https://media.skycompass.io/assets/customizes/jobs/1138x1138/{id}_{0|1}.png`
|
||||
|
||||
**Example:**
|
||||
```ruby
|
||||
downloader = Granblue::Downloaders::JobDownloader.new(
|
||||
"100401",
|
||||
storage: :both,
|
||||
verbose: true
|
||||
)
|
||||
downloader.download # Downloads both wide and zoom with gender variants
|
||||
downloader.download("zoom") # Downloads only zoom images
|
||||
```
|
||||
|
||||
## Rake Tasks
|
||||
|
||||
### Download Images for Specific Items
|
||||
|
||||
```bash
|
||||
# Character images
|
||||
rake granblue:export:character_images[3040001000]
|
||||
rake granblue:export:character_images[3040001000,false,true,s3,grid]
|
||||
|
||||
# Weapon images
|
||||
rake granblue:export:weapon_images[1040001000]
|
||||
rake granblue:export:weapon_images[1040001000,false,true,both,main]
|
||||
|
||||
# Summon images
|
||||
rake granblue:export:summon_images[2040001000]
|
||||
rake granblue:export:summon_images[2040001000,false,true,local]
|
||||
|
||||
# Job images
|
||||
rake granblue:export:job_images[100401]
|
||||
rake granblue:export:job_images[100401,false,true,both,zoom]
|
||||
```
|
||||
|
||||
### Bulk Download All Images
|
||||
|
||||
```bash
|
||||
# Download all images for a type with parallel processing
|
||||
rake granblue:download_all_images[character,4] # 4 threads
|
||||
rake granblue:download_all_images[weapon,8,grid] # 8 threads, grid only
|
||||
rake granblue:download_all_images[summon,4,square] # 4 threads, square only
|
||||
rake granblue:download_all_images[job,2] # 2 threads, all sizes
|
||||
|
||||
# Download all with specific parameters
|
||||
rake granblue:export:character_images[,false,true,s3] # All characters to S3
|
||||
rake granblue:export:weapon_images[,true] # Test mode
|
||||
rake granblue:export:summon_images[,false,false,local] # Local only, quiet
|
||||
rake granblue:export:job_images[,false,true,both] # Both storages
|
||||
```
|
||||
|
||||
## Storage Options
|
||||
|
||||
### Local Storage
|
||||
Files are saved to `Rails.root/download/`:
|
||||
```
|
||||
download/
|
||||
├── character-main/
|
||||
├── character-grid/
|
||||
├── character-square/
|
||||
├── character-detail/
|
||||
├── weapon-main/
|
||||
├── weapon-grid/
|
||||
├── weapon-square/
|
||||
├── summon-main/
|
||||
├── summon-grid/
|
||||
├── summon-square/
|
||||
├── job-wide/
|
||||
└── job-zoom/
|
||||
```
|
||||
|
||||
### S3 Storage
|
||||
Files are uploaded with the following key structure:
|
||||
```
|
||||
{object_type}-{size}/{filename}
|
||||
|
||||
Examples:
|
||||
character-grid/3040001000_01.jpg
|
||||
weapon-main/1040001000.jpg
|
||||
summon-square/2040001000.jpg
|
||||
job-zoom/100401_a.png
|
||||
job-zoom/100401_b.png
|
||||
```
|
||||
|
||||
### Storage Mode Selection
|
||||
```ruby
|
||||
storage: :local # Save to filesystem only
|
||||
storage: :s3 # Upload to S3 only
|
||||
storage: :both # Save locally AND upload to S3 (default)
|
||||
```
|
||||
|
||||
## Parameters Reference
|
||||
|
||||
### Common Parameters
|
||||
All downloaders accept these parameters:
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `id` | String | required | Granblue ID of the item |
|
||||
| `test_mode` | Boolean | false | Simulate downloads without downloading |
|
||||
| `verbose` | Boolean | false | Enable detailed logging |
|
||||
| `storage` | Symbol | :both | Storage mode (:local, :s3, :both) |
|
||||
| `logger` | Logger | Rails.logger | Logger instance to use |
|
||||
|
||||
### Rake Task Parameters
|
||||
For rake tasks, parameters are passed in order:
|
||||
|
||||
```bash
|
||||
rake task_name[id,test_mode,verbose,storage,size]
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# All parameters
|
||||
rake granblue:export:character_images[3040001000,false,true,both,grid]
|
||||
|
||||
# Skip parameters with commas
|
||||
rake granblue:export:weapon_images[,true,true] # All weapons, test mode, verbose
|
||||
|
||||
# Partial parameters
|
||||
rake granblue:export:summon_images[2040001000,false,true,s3]
|
||||
```
|
||||
|
||||
## Test Mode
|
||||
|
||||
Test mode simulates downloads without actually downloading files:
|
||||
|
||||
```ruby
|
||||
# Ruby
|
||||
downloader = Granblue::Downloaders::CharacterDownloader.new(
|
||||
"3040001000",
|
||||
test_mode: true,
|
||||
verbose: true
|
||||
)
|
||||
downloader.download
|
||||
|
||||
# Rake task
|
||||
rake granblue:export:character_images[3040001000,true,true]
|
||||
```
|
||||
|
||||
Output in test mode:
|
||||
```
|
||||
-> 3040001000
|
||||
(Test mode - would download images)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 404 Errors
|
||||
When an image doesn't exist on the server, the downloader logs it and continues:
|
||||
```
|
||||
├ grid: https://example.com/image.jpg...
|
||||
404 returned https://example.com/image.jpg
|
||||
```
|
||||
|
||||
### Network Errors
|
||||
Network errors are caught and logged, allowing the process to continue:
|
||||
```ruby
|
||||
begin
|
||||
download_to_local(url, path)
|
||||
rescue OpenURI::HTTPError => e
|
||||
log_info "404 returned\t#{url}"
|
||||
rescue StandardError => e
|
||||
log_info "Error downloading #{url}: #{e.message}"
|
||||
end
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Parallel Processing for Bulk Downloads
|
||||
```bash
|
||||
# Good - uses multiple threads
|
||||
rake granblue:download_all_images[character,8]
|
||||
|
||||
# Less efficient - sequential
|
||||
rake granblue:export:character_images
|
||||
```
|
||||
|
||||
### 2. Check Test Mode First
|
||||
Before running bulk operations:
|
||||
```bash
|
||||
# Test first
|
||||
rake granblue:export:character_images[,true,true]
|
||||
|
||||
# Then run for real
|
||||
rake granblue:export:character_images[,false,true]
|
||||
```
|
||||
|
||||
### 3. Use Specific Sizes When Needed
|
||||
If you only need certain image sizes:
|
||||
```bash
|
||||
# Download only grid images (faster)
|
||||
rake granblue:download_all_images[weapon,4,grid]
|
||||
|
||||
# Instead of all sizes
|
||||
rake granblue:download_all_images[weapon,4]
|
||||
```
|
||||
|
||||
### 4. Monitor S3 Usage
|
||||
When using S3 storage:
|
||||
- Check AWS costs regularly
|
||||
- Use lifecycle policies for old images
|
||||
- Consider CDN caching for frequently accessed images
|
||||
|
||||
## Custom Downloader Implementation
|
||||
|
||||
To create a custom downloader:
|
||||
|
||||
```ruby
|
||||
module Granblue
|
||||
module Downloaders
|
||||
class CustomDownloader < BaseDownloader
|
||||
# Define available sizes
|
||||
SIZES = %w[large small].freeze
|
||||
|
||||
# Required: specify object type
|
||||
def object_type
|
||||
'custom'
|
||||
end
|
||||
|
||||
# Required: base URL for assets
|
||||
def base_url
|
||||
'https://example.com/assets'
|
||||
end
|
||||
|
||||
# Required: map size to directory
|
||||
def directory_for_size(size)
|
||||
case size
|
||||
when 'large' then 'lg'
|
||||
when 'small' then 'sm'
|
||||
end
|
||||
end
|
||||
|
||||
# Optional: custom download logic
|
||||
def download(selected_size = nil)
|
||||
# Custom implementation
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Images Not Downloading
|
||||
1. Check network connectivity
|
||||
2. Verify the Granblue ID exists in the database
|
||||
3. Ensure the asset exists on the game server
|
||||
4. Check storage permissions (filesystem or S3)
|
||||
|
||||
### S3 Upload Failures
|
||||
1. Verify AWS credentials are configured
|
||||
2. Check S3 bucket permissions
|
||||
3. Ensure bucket exists and is accessible
|
||||
4. Check for S3 rate limiting
|
||||
|
||||
### Slow Downloads
|
||||
1. Use parallel processing with more threads
|
||||
2. Download specific sizes instead of all
|
||||
3. Check network bandwidth
|
||||
4. Consider using S3 only mode to avoid local I/O
|
||||
|
||||
### Debugging
|
||||
Enable verbose mode for detailed output:
|
||||
```bash
|
||||
rake granblue:export:character_images[3040001000,false,true]
|
||||
```
|
||||
|
||||
Check Rails logs:
|
||||
```bash
|
||||
tail -f log/development.log
|
||||
```
|
||||
1179
docs/implementation/artifacts-feature-implementation.md
Normal file
1179
docs/implementation/artifacts-feature-implementation.md
Normal file
File diff suppressed because it is too large
Load diff
1043
docs/implementation/collection-tracking-implementation.md
Normal file
1043
docs/implementation/collection-tracking-implementation.md
Normal file
File diff suppressed because it is too large
Load diff
1466
docs/implementation/crew-feature-implementation.md
Normal file
1466
docs/implementation/crew-feature-implementation.md
Normal file
File diff suppressed because it is too large
Load diff
400
docs/importers.md
Normal file
400
docs/importers.md
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
# Data Importers Documentation
|
||||
|
||||
The importer system provides a framework for importing game data from CSV files into the database. It supports test mode for validation, tracks new and updated records, and provides detailed error reporting.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Base Importer
|
||||
|
||||
All importers inherit from `BaseImporter` which provides:
|
||||
- CSV parsing and validation
|
||||
- Test mode for dry runs
|
||||
- New and updated record tracking
|
||||
- Error handling and reporting
|
||||
- Verbose logging support
|
||||
- Batch processing with ActiveRecord transactions
|
||||
|
||||
### Available Importers
|
||||
|
||||
#### CharacterImporter
|
||||
Imports character data from CSV files.
|
||||
|
||||
**Required CSV Fields:**
|
||||
- `granblue_id` - Unique character ID
|
||||
- `name_en` - English name
|
||||
- `name_jp` - Japanese name
|
||||
- `rarity` - Character rarity
|
||||
- `element` - Element type
|
||||
- `flb` - Has 5★ uncap (true/false)
|
||||
- `ulb` - Has 6★ uncap (true/false)
|
||||
- `wiki_en` - Wiki page name
|
||||
|
||||
**Example CSV:**
|
||||
```csv
|
||||
granblue_id,name_en,name_jp,rarity,element,flb,ulb,wiki_en
|
||||
3040001000,Katalina,カタリナ,4,3,true,false,Katalina
|
||||
```
|
||||
|
||||
#### WeaponImporter
|
||||
Imports weapon data from CSV files.
|
||||
|
||||
**Required CSV Fields:**
|
||||
- `granblue_id` - Unique weapon ID
|
||||
- `name_en` - English name
|
||||
- `name_jp` - Japanese name
|
||||
- `rarity` - Weapon rarity
|
||||
- `element` - Element type
|
||||
- `weapon_type` - Type of weapon
|
||||
- `wiki_en` - Wiki page name
|
||||
|
||||
**Example CSV:**
|
||||
```csv
|
||||
granblue_id,name_en,name_jp,rarity,element,weapon_type,wiki_en
|
||||
1040001000,Murgleis,ミュルグレス,5,3,1,Murgleis
|
||||
```
|
||||
|
||||
#### SummonImporter
|
||||
Imports summon data from CSV files.
|
||||
|
||||
**Required CSV Fields:**
|
||||
- `granblue_id` - Unique summon ID
|
||||
- `name_en` - English name
|
||||
- `name_jp` - Japanese name
|
||||
- `rarity` - Summon rarity
|
||||
- `element` - Element type
|
||||
- `max_level` - Maximum level
|
||||
- `wiki_en` - Wiki page name
|
||||
|
||||
**Example CSV:**
|
||||
```csv
|
||||
granblue_id,name_en,name_jp,rarity,element,max_level,wiki_en
|
||||
2040001000,Bahamut,バハムート,5,0,150,Bahamut
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Ruby API
|
||||
|
||||
```ruby
|
||||
# Import character data
|
||||
importer = Granblue::Importers::CharacterImporter.new(
|
||||
'db/seed/updates/characters.csv',
|
||||
test_mode: false,
|
||||
verbose: true
|
||||
)
|
||||
result = importer.import
|
||||
|
||||
# Check results
|
||||
result[:new_records] # => Array of newly created records
|
||||
result[:updated_records] # => Array of updated records
|
||||
result[:errors] # => Array of error messages
|
||||
|
||||
# Test mode - validate without importing
|
||||
test_importer = Granblue::Importers::WeaponImporter.new(
|
||||
'db/seed/updates/weapons.csv',
|
||||
test_mode: true,
|
||||
verbose: true
|
||||
)
|
||||
test_result = test_importer.import
|
||||
```
|
||||
|
||||
### Rake Task
|
||||
|
||||
The main import task processes all CSV files in `db/seed/updates/`:
|
||||
|
||||
```bash
|
||||
# Import all CSV files
|
||||
rake granblue:import_data
|
||||
|
||||
# Test mode - validate without importing
|
||||
rake granblue:import_data TEST=true
|
||||
|
||||
# Verbose output
|
||||
rake granblue:import_data VERBOSE=true
|
||||
|
||||
# Both test and verbose
|
||||
rake granblue:import_data TEST=true VERBOSE=true
|
||||
```
|
||||
|
||||
## CSV File Format
|
||||
|
||||
### File Location
|
||||
Place CSV files in: `db/seed/updates/`
|
||||
|
||||
### Naming Convention
|
||||
- `characters_YYYYMMDD.csv` - Character data
|
||||
- `weapons_YYYYMMDD.csv` - Weapon data
|
||||
- `summons_YYYYMMDD.csv` - Summon data
|
||||
|
||||
### Encoding
|
||||
- UTF-8 encoding required
|
||||
- Unix line endings (LF) preferred
|
||||
|
||||
### Headers
|
||||
- First row must contain field names
|
||||
- Field names are case-sensitive
|
||||
- Order doesn't matter
|
||||
|
||||
### Data Types
|
||||
- **Strings**: Plain text, quotes optional unless contains commas
|
||||
- **Numbers**: Integer or decimal values
|
||||
- **Booleans**: `true` or `false` (lowercase)
|
||||
- **Dates**: ISO 8601 format (YYYY-MM-DD)
|
||||
- **Empty values**: Leave blank or use empty string
|
||||
|
||||
## Field Mappings
|
||||
|
||||
### Element Values
|
||||
```
|
||||
0 = Null/None
|
||||
1 = Wind
|
||||
2 = Fire
|
||||
3 = Water
|
||||
4 = Earth
|
||||
5 = Dark
|
||||
6 = Light
|
||||
```
|
||||
|
||||
### Weapon Types
|
||||
```
|
||||
1 = Sword
|
||||
2 = Dagger
|
||||
3 = Spear
|
||||
4 = Axe
|
||||
5 = Staff
|
||||
6 = Gun
|
||||
7 = Melee
|
||||
8 = Bow
|
||||
9 = Harp
|
||||
10 = Katana
|
||||
```
|
||||
|
||||
### Rarity Values
|
||||
```
|
||||
1 = R (Rare)
|
||||
2 = SR (Super Rare)
|
||||
3 = SSR (Super Super Rare)
|
||||
4 = SSR+
|
||||
5 = Grand/Limited
|
||||
```
|
||||
|
||||
## Test Mode
|
||||
|
||||
Test mode validates data without making database changes:
|
||||
|
||||
```bash
|
||||
rake granblue:import_data TEST=true
|
||||
```
|
||||
|
||||
Test mode will:
|
||||
1. Parse CSV files
|
||||
2. Validate all data
|
||||
3. Check for duplicates
|
||||
4. Report what would be created/updated
|
||||
5. Show any validation errors
|
||||
6. **NOT** save to database
|
||||
|
||||
Output example:
|
||||
```
|
||||
[TEST MODE] Would create Character: Katalina (3040001000)
|
||||
[TEST MODE] Would update Weapon: Murgleis (1040001000)
|
||||
[TEST MODE] Validation error for Summon: Invalid element value
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Import Errors
|
||||
|
||||
The importer tracks various error types:
|
||||
|
||||
```ruby
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
row: 5,
|
||||
field: 'element',
|
||||
message: 'Invalid element value: 99',
|
||||
record: { granblue_id: '3040001000', name_en: 'Katalina' }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
Records are validated before save:
|
||||
- Required fields must be present
|
||||
- Granblue ID must be unique
|
||||
- Element must be valid (0-6)
|
||||
- Rarity must be valid (1-5)
|
||||
|
||||
### Duplicate Handling
|
||||
|
||||
When a record with the same `granblue_id` exists:
|
||||
1. Existing record is updated with new values
|
||||
2. Update is tracked in `updated_records`
|
||||
3. Original values are preserved in update log
|
||||
|
||||
## Batch Processing
|
||||
|
||||
The import system uses transactions for efficiency:
|
||||
|
||||
```ruby
|
||||
ActiveRecord::Base.transaction do
|
||||
records.each do |record|
|
||||
# Process record
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- All-or-nothing imports
|
||||
- Better performance
|
||||
- Automatic rollback on errors
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Test First
|
||||
```bash
|
||||
# Test mode first
|
||||
rake granblue:import_data TEST=true VERBOSE=true
|
||||
|
||||
# Review output, then import
|
||||
rake granblue:import_data VERBOSE=true
|
||||
```
|
||||
|
||||
### 2. Use Dated Filenames
|
||||
```
|
||||
db/seed/updates/
|
||||
├── characters_20240101.csv
|
||||
├── weapons_20240115.csv
|
||||
└── summons_20240201.csv
|
||||
```
|
||||
|
||||
### 3. Validate Data Format
|
||||
Before importing:
|
||||
- Check CSV encoding (UTF-8)
|
||||
- Verify headers match expected fields
|
||||
- Validate element and rarity values
|
||||
- Ensure granblue_id uniqueness
|
||||
|
||||
### 4. Backup Before Large Imports
|
||||
```bash
|
||||
# Backup database
|
||||
pg_dump hensei_development > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# Run import
|
||||
rake granblue:import_data
|
||||
|
||||
# If issues, restore
|
||||
psql hensei_development < backup_20240315.sql
|
||||
```
|
||||
|
||||
### 5. Monitor Import Results
|
||||
```ruby
|
||||
# In Rails console
|
||||
import_log = ImportLog.last
|
||||
import_log.new_records_count
|
||||
import_log.updated_records_count
|
||||
import_log.errors
|
||||
```
|
||||
|
||||
## Custom Importer Implementation
|
||||
|
||||
To create a custom importer:
|
||||
|
||||
```ruby
|
||||
module Granblue
|
||||
module Importers
|
||||
class CustomImporter < BaseImporter
|
||||
private
|
||||
|
||||
# Required: specify model class
|
||||
def model_class
|
||||
CustomModel
|
||||
end
|
||||
|
||||
# Required: build attributes from CSV row
|
||||
def build_attributes(row)
|
||||
{
|
||||
granblue_id: parse_value(row['granblue_id']),
|
||||
name_en: parse_value(row['name_en']),
|
||||
name_jp: parse_value(row['name_jp']),
|
||||
custom_field: parse_custom_value(row['custom'])
|
||||
}
|
||||
end
|
||||
|
||||
# Optional: custom parsing logic
|
||||
def parse_custom_value(value)
|
||||
# Custom parsing
|
||||
value.to_s.upcase
|
||||
end
|
||||
|
||||
# Optional: additional validation
|
||||
def validate_record(attributes)
|
||||
errors = []
|
||||
if attributes[:custom_field].blank?
|
||||
errors << "Custom field is required"
|
||||
end
|
||||
errors
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Data Pipeline
|
||||
|
||||
Complete data import pipeline:
|
||||
|
||||
1. **Export from source** → CSV files
|
||||
2. **Place in updates folder** → `db/seed/updates/`
|
||||
3. **Test import** → `rake granblue:import_data TEST=true`
|
||||
4. **Review results** → Check logs for errors
|
||||
5. **Execute import** → `rake granblue:import_data`
|
||||
6. **Download images** → `rake granblue:download_all_images[type]`
|
||||
7. **Fetch wiki data** → `rake granblue:fetch_wiki_data`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Import Not Finding Files
|
||||
1. Check files are in `db/seed/updates/`
|
||||
2. Verify file extensions are `.csv`
|
||||
3. Ensure file permissions allow reading
|
||||
|
||||
### Validation Errors
|
||||
1. Check CSV headers match expected fields
|
||||
2. Verify data types (strings, numbers, booleans)
|
||||
3. Validate element and rarity values
|
||||
4. Ensure granblue_id is unique
|
||||
|
||||
### Encoding Issues
|
||||
1. Save CSV as UTF-8
|
||||
2. Remove BOM if present
|
||||
3. Use Unix line endings (LF)
|
||||
4. Check for special characters
|
||||
|
||||
### Performance Issues
|
||||
For large imports:
|
||||
1. Use batch processing
|
||||
2. Disable callbacks if safe
|
||||
3. Consider direct SQL for bulk operations
|
||||
4. Import in smaller chunks
|
||||
|
||||
### Debugging
|
||||
Enable verbose mode:
|
||||
```bash
|
||||
rake granblue:import_data VERBOSE=true
|
||||
```
|
||||
|
||||
Check Rails console:
|
||||
```ruby
|
||||
# Recent imports
|
||||
ImportLog.recent
|
||||
|
||||
# Failed imports
|
||||
ImportLog.where(status: 'failed')
|
||||
|
||||
# Check specific record
|
||||
Character.find_by(granblue_id: '3040001000')
|
||||
```
|
||||
468
docs/parsers.md
Normal file
468
docs/parsers.md
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
# Wiki Parsers Documentation
|
||||
|
||||
The parser system extracts and processes data from the Granblue Fantasy Wiki. It fetches wiki pages, parses wikitext format, and extracts structured data for characters, weapons, and summons.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Base Parser
|
||||
|
||||
All parsers inherit from `BaseParser` which provides:
|
||||
- Wiki page fetching via MediaWiki API
|
||||
- Redirect handling
|
||||
- Wikitext parsing
|
||||
- Template extraction
|
||||
- Error handling and debugging
|
||||
- Local cache support
|
||||
|
||||
### Wiki Client
|
||||
|
||||
The `Wiki` class handles API communication:
|
||||
- MediaWiki API integration
|
||||
- Page content fetching
|
||||
- Redirect detection
|
||||
- Rate limiting
|
||||
- Error handling
|
||||
|
||||
### Available Parsers
|
||||
|
||||
#### CharacterParser
|
||||
Extracts character data from wiki pages.
|
||||
|
||||
**Extracted Data:**
|
||||
- Character stats (HP, ATK)
|
||||
- Skills and abilities
|
||||
- Charge attack details
|
||||
- Voice actor information
|
||||
- Release dates
|
||||
- Character metadata
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
character = Character.find_by(granblue_id: "3040001000")
|
||||
parser = Granblue::Parsers::CharacterParser.new(character)
|
||||
|
||||
# Fetch and parse wiki data
|
||||
data = parser.fetch(save: false)
|
||||
|
||||
# Fetch, parse, and save to database
|
||||
parser.fetch(save: true)
|
||||
|
||||
# Use local cached wiki data
|
||||
parser = Granblue::Parsers::CharacterParser.new(character, use_local: true)
|
||||
data = parser.fetch
|
||||
```
|
||||
|
||||
#### WeaponParser
|
||||
Extracts weapon data from wiki pages.
|
||||
|
||||
**Extracted Data:**
|
||||
- Weapon stats (HP, ATK)
|
||||
- Weapon skills
|
||||
- Ougi (charge attack) effects
|
||||
- Crafting requirements
|
||||
- Upgrade materials
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
weapon = Weapon.find_by(granblue_id: "1040001000")
|
||||
parser = Granblue::Parsers::WeaponParser.new(weapon)
|
||||
data = parser.fetch(save: true)
|
||||
```
|
||||
|
||||
#### SummonParser
|
||||
Extracts summon data from wiki pages.
|
||||
|
||||
**Extracted Data:**
|
||||
- Summon stats (HP, ATK)
|
||||
- Call effects
|
||||
- Aura effects
|
||||
- Cooldown information
|
||||
- Sub-aura details
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
summon = Summon.find_by(granblue_id: "2040001000")
|
||||
parser = Granblue::Parsers::SummonParser.new(summon)
|
||||
data = parser.fetch(save: true)
|
||||
```
|
||||
|
||||
#### CharacterSkillParser
|
||||
Parses individual character skills.
|
||||
|
||||
**Extracted Data:**
|
||||
- Skill name and description
|
||||
- Cooldown and duration
|
||||
- Effect values by level
|
||||
- Skill upgrade requirements
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
parser = Granblue::Parsers::CharacterSkillParser.new(skill_text)
|
||||
skill_data = parser.parse
|
||||
```
|
||||
|
||||
#### WeaponSkillParser
|
||||
Parses weapon skill information.
|
||||
|
||||
**Extracted Data:**
|
||||
- Skill name and type
|
||||
- Effect percentages
|
||||
- Skill level scaling
|
||||
- Awakening effects
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
parser = Granblue::Parsers::WeaponSkillParser.new(skill_text)
|
||||
skill_data = parser.parse
|
||||
```
|
||||
|
||||
## Rake Tasks
|
||||
|
||||
### Fetch Wiki Data
|
||||
|
||||
```bash
|
||||
# Fetch all characters
|
||||
rake granblue:fetch_wiki_data
|
||||
|
||||
# Fetch specific type
|
||||
rake granblue:fetch_wiki_data type=Weapon
|
||||
rake granblue:fetch_wiki_data type=Summon
|
||||
|
||||
# Fetch specific item
|
||||
rake granblue:fetch_wiki_data type=Character id=3040001000
|
||||
|
||||
# Force re-fetch even if data exists
|
||||
rake granblue:fetch_wiki_data force=true
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Values | Default | Description |
|
||||
|-----------|--------|---------|-------------|
|
||||
| `type` | Character, Weapon, Summon | Character | Type of object to fetch |
|
||||
| `id` | Granblue ID | all | Specific item or all |
|
||||
| `force` | true/false | false | Re-fetch even if wiki_raw exists |
|
||||
|
||||
## Wiki Data Storage
|
||||
|
||||
### Database Fields
|
||||
|
||||
Each model has wiki-related fields:
|
||||
- `wiki_en` - English wiki page name
|
||||
- `wiki_jp` - Japanese wiki page name (if available)
|
||||
- `wiki_raw` - Raw wikitext cache
|
||||
- `wiki_updated_at` - Last fetch timestamp
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
1. **Initial Fetch**: Wiki data fetched from API
|
||||
2. **Raw Storage**: Wikitext stored in `wiki_raw`
|
||||
3. **Local Parsing**: Parsers use cached data when available
|
||||
4. **Refresh**: Force flag bypasses cache
|
||||
|
||||
## Wikitext Format
|
||||
|
||||
### Templates
|
||||
Wiki pages use templates for structured data:
|
||||
```
|
||||
{{Character
|
||||
|id=3040001000
|
||||
|name=Katalina
|
||||
|element=Water
|
||||
|rarity=SSR
|
||||
|hp=1680
|
||||
|atk=7200
|
||||
}}
|
||||
```
|
||||
|
||||
### Tables
|
||||
Stats and skills in table format:
|
||||
```
|
||||
{| class="wikitable"
|
||||
! Level !! HP !! ATK
|
||||
|-
|
||||
| 1 || 280 || 1200
|
||||
|-
|
||||
| 100 || 1680 || 7200
|
||||
|}
|
||||
```
|
||||
|
||||
### Skills
|
||||
Skill descriptions with effects:
|
||||
```
|
||||
|skill1_name = Blade of Light
|
||||
|skill1_desc = 400% Water damage to one enemy
|
||||
|skill1_cd = 7 turns
|
||||
```
|
||||
|
||||
## Parser Implementation
|
||||
|
||||
### Basic Parser Structure
|
||||
|
||||
```ruby
|
||||
module Granblue
|
||||
module Parsers
|
||||
class CustomParser < BaseParser
|
||||
def parse_content(wikitext)
|
||||
data = {}
|
||||
|
||||
# Extract template data
|
||||
template = extract_template(wikitext)
|
||||
data[:name] = template['name']
|
||||
data[:element] = parse_element(template['element'])
|
||||
|
||||
# Parse tables
|
||||
tables = extract_tables(wikitext)
|
||||
data[:stats] = parse_stat_table(tables.first)
|
||||
|
||||
# Parse skills
|
||||
data[:skills] = parse_skills(wikitext)
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_element(element_text)
|
||||
case element_text.downcase
|
||||
when 'fire' then 2
|
||||
when 'water' then 3
|
||||
when 'earth' then 4
|
||||
when 'wind' then 1
|
||||
when 'light' then 6
|
||||
when 'dark' then 5
|
||||
else 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Template Extraction
|
||||
|
||||
```ruby
|
||||
def extract_template(wikitext)
|
||||
template_match = wikitext.match(/\{\{(\w+)(.*?)\}\}/m)
|
||||
return {} unless template_match
|
||||
|
||||
template_name = template_match[1]
|
||||
template_content = template_match[2]
|
||||
|
||||
params = {}
|
||||
template_content.scan(/\|(\w+)\s*=\s*([^\|]*)/) do |key, value|
|
||||
params[key] = value.strip
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
```
|
||||
|
||||
### Table Parsing
|
||||
|
||||
```ruby
|
||||
def extract_tables(wikitext)
|
||||
tables = []
|
||||
wikitext.scan(/\{\|.*?\|\}/m) do |table|
|
||||
rows = []
|
||||
table.scan(/\|-\s*(.*?)(?=\|-|\|\})/m) do |row|
|
||||
cells = row[0].split('||').map(&:strip)
|
||||
rows << cells unless cells.empty?
|
||||
end
|
||||
tables << rows
|
||||
end
|
||||
tables
|
||||
end
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Redirect Handling
|
||||
When a page redirects:
|
||||
```ruby
|
||||
# Automatic redirect detection
|
||||
redirect_match = wikitext.match(/#REDIRECT \[\[(.*?)\]\]/)
|
||||
if redirect_match
|
||||
# Update wiki_en to new page
|
||||
object.update!(wiki_en: redirect_match[1])
|
||||
# Fetch new page
|
||||
fetch_wiki_info(redirect_match[1])
|
||||
end
|
||||
```
|
||||
|
||||
### API Errors
|
||||
Common errors and handling:
|
||||
```ruby
|
||||
begin
|
||||
response = wiki_client.fetch(page_name)
|
||||
rescue Net::ReadTimeout
|
||||
Rails.logger.error "Wiki API timeout for #{page_name}"
|
||||
return nil
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Invalid wiki response: #{e.message}"
|
||||
return nil
|
||||
end
|
||||
```
|
||||
|
||||
### Parse Errors
|
||||
Safe parsing with defaults:
|
||||
```ruby
|
||||
def safe_parse_integer(value, default = 0)
|
||||
Integer(value.to_s.gsub(/[^\d]/, ''))
|
||||
rescue ArgumentError
|
||||
default
|
||||
end
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Cache Wiki Data
|
||||
```bash
|
||||
# Fetch and cache all wiki data first
|
||||
rake granblue:fetch_wiki_data type=Character
|
||||
rake granblue:fetch_wiki_data type=Weapon
|
||||
rake granblue:fetch_wiki_data type=Summon
|
||||
|
||||
# Then parse using cached data
|
||||
parser = CharacterParser.new(character, use_local: true)
|
||||
```
|
||||
|
||||
### 2. Handle Missing Pages
|
||||
```ruby
|
||||
if object.wiki_en.blank?
|
||||
Rails.logger.warn "No wiki page for #{object.name_en}"
|
||||
return nil
|
||||
end
|
||||
```
|
||||
|
||||
### 3. Validate Parsed Data
|
||||
```ruby
|
||||
data = parser.fetch
|
||||
if data[:hp].nil? || data[:atk].nil?
|
||||
Rails.logger.error "Missing required stats for #{object.name_en}"
|
||||
end
|
||||
```
|
||||
|
||||
### 4. Rate Limiting
|
||||
```ruby
|
||||
# Add delays between requests
|
||||
objects.each do |object|
|
||||
parser = CharacterParser.new(object)
|
||||
parser.fetch
|
||||
sleep(1) # Respect wiki rate limits
|
||||
end
|
||||
```
|
||||
|
||||
### 5. Error Recovery
|
||||
```ruby
|
||||
begin
|
||||
data = parser.fetch(save: true)
|
||||
rescue => e
|
||||
Rails.logger.error "Parse failed: #{e.message}"
|
||||
# Try with cached data
|
||||
parser = CharacterParser.new(object, use_local: true)
|
||||
data = parser.fetch
|
||||
end
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Debug Mode
|
||||
```ruby
|
||||
parser = Granblue::Parsers::CharacterParser.new(
|
||||
character,
|
||||
debug: true
|
||||
)
|
||||
data = parser.fetch
|
||||
```
|
||||
|
||||
Debug output shows:
|
||||
- API requests made
|
||||
- Template data extracted
|
||||
- Parsing steps
|
||||
- Data transformations
|
||||
|
||||
### Inspect Raw Wiki Data
|
||||
```ruby
|
||||
# In Rails console
|
||||
character = Character.find_by(granblue_id: "3040001000")
|
||||
puts character.wiki_raw
|
||||
|
||||
# Check for specific content
|
||||
character.wiki_raw.include?("charge_attack")
|
||||
```
|
||||
|
||||
### Test Parsing
|
||||
```ruby
|
||||
# Test with sample wikitext
|
||||
sample = "{{Character|name=Test|hp=1000}}"
|
||||
parser = CharacterParser.new(character)
|
||||
data = parser.parse_content(sample)
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Field Extraction
|
||||
```ruby
|
||||
class CustomParser < BaseParser
|
||||
def parse_custom_field(wikitext)
|
||||
# Extract custom pattern
|
||||
if match = wikitext.match(/custom_pattern:\s*(.+)/)
|
||||
match[1].strip
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
```ruby
|
||||
# Process in batches to avoid memory issues
|
||||
Character.find_in_batches(batch_size: 100) do |batch|
|
||||
batch.each do |character|
|
||||
next if character.wiki_raw.present?
|
||||
|
||||
parser = CharacterParser.new(character)
|
||||
parser.fetch(save: true)
|
||||
sleep(1)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Parallel Processing
|
||||
```ruby
|
||||
require 'parallel'
|
||||
|
||||
characters = Character.where(wiki_raw: nil)
|
||||
Parallel.each(characters, in_threads: 4) do |character|
|
||||
ActiveRecord::Base.connection_pool.with_connection do
|
||||
parser = CharacterParser.new(character)
|
||||
parser.fetch(save: true)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Wiki Page Not Found
|
||||
1. Verify wiki_en field has correct page name
|
||||
2. Check for redirects on wiki
|
||||
3. Try searching wiki manually
|
||||
4. Update wiki_en if page moved
|
||||
|
||||
### Parsing Returns Empty Data
|
||||
1. Check wiki_raw has content
|
||||
2. Verify template format hasn't changed
|
||||
3. Enable debug mode to see parsing steps
|
||||
4. Check for wiki page format changes
|
||||
|
||||
### API Timeouts
|
||||
1. Increase timeout in Wiki client
|
||||
2. Add retry logic
|
||||
3. Use cached data when available
|
||||
4. Process in smaller batches
|
||||
|
||||
### Data Inconsistencies
|
||||
1. Force re-fetch with `force=true`
|
||||
2. Clear wiki_raw and re-fetch
|
||||
3. Check wiki edit history for changes
|
||||
4. Compare with other items of same type
|
||||
385
docs/planning/artifacts-feature-plan.md
Normal file
385
docs/planning/artifacts-feature-plan.md
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
# Artifacts Feature Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Artifacts are character equipment items in Granblue Fantasy that provide stat bonuses through a skill system. This document outlines the implementation plan for tracking artifacts in user collections and parties, following the same pattern as the existing weapon/summon collection system.
|
||||
|
||||
## Business Requirements
|
||||
|
||||
### User Stories
|
||||
|
||||
1. **As a user**, I want to record artifacts I own in the game so I can track my collection
|
||||
2. **As a user**, I want to save artifact skill configurations so I can reference them when team building
|
||||
3. **As a user**, I want to track multiple copies of the same artifact with different skill configurations
|
||||
4. **As a user**, I want to equip artifacts from my collection to characters in parties
|
||||
5. **As a user**, I want to quick-build artifacts directly in parties without adding to my collection
|
||||
6. **As a user**, I want to see which characters have artifacts equipped in my parties
|
||||
|
||||
### Core Concepts
|
||||
|
||||
#### Collection vs Grid Artifacts
|
||||
- **Collection Artifacts**: Represent artifacts in the user's inventory, independent of any party
|
||||
- **Grid Artifacts**: Represent artifacts equipped to characters within a specific party
|
||||
|
||||
#### Artifact Types
|
||||
- **Standard Artifacts**: Max level 150, 3 skill slots
|
||||
- **Quirk Artifacts**: Max level 200, 4 skill slots (character-specific)
|
||||
|
||||
#### Skill System
|
||||
- **Group I Skills**: Attack, HP, critical rate, etc. (max 2 per artifact)
|
||||
- **Group II Skills**: Enmity, stamina, charge bar speed, etc. (max 1 per artifact)
|
||||
- **Group III Skills**: Damage cap increases (max 1 per artifact)
|
||||
|
||||
Each skill has its own level that users can set when recording their artifacts.
|
||||
|
||||
#### Item Uniqueness Rules
|
||||
- **Artifacts**: Multiple instances of the same artifact allowed per user, each with unique skill configurations
|
||||
- **Grid Artifacts**: One artifact per character in a party
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Database Schema
|
||||
|
||||
#### artifacts (Canonical Game Data)
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- name_en: string (not null)
|
||||
- name_jp: string (not null)
|
||||
- series: integer (1=Ominous, 2=Saint, 3=Jinyao, etc.)
|
||||
- weapon_specialty: integer (1=sabre, 2=dagger, etc.)
|
||||
- rarity: integer (3=R, 4=SR, 5=SSR)
|
||||
- is_quirk: boolean (default false)
|
||||
- max_level: integer (default 5 for standard, 1 for quirk)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- index on weapon_specialty
|
||||
- index on rarity
|
||||
- index on is_quirk
|
||||
```
|
||||
|
||||
#### artifact_skills (Canonical Game Data)
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- name_en: string (not null)
|
||||
- name_jp: string (not null)
|
||||
- skill_group: integer (1=Group I, 2=Group II, 3=Group III)
|
||||
- effect_type: string (atk, hp, ca_dmg, skill_dmg, etc.)
|
||||
- base_values: jsonb (array of possible starting values)
|
||||
- growth_value: decimal (amount gained per level)
|
||||
- max_level: integer (default 5)
|
||||
- description_en: text
|
||||
- description_jp: text
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- index on skill_group
|
||||
- index on effect_type
|
||||
```
|
||||
|
||||
|
||||
#### collection_artifacts (User Collection)
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- user_id: uuid (foreign key to users, not null)
|
||||
- artifact_id: uuid (foreign key to artifacts, not null)
|
||||
- level: integer (1-200)
|
||||
- skill1_id: uuid (foreign key to artifact_skills)
|
||||
- skill1_level: integer (1-15)
|
||||
- skill2_id: uuid (foreign key to artifact_skills)
|
||||
- skill2_level: integer (1-15)
|
||||
- skill3_id: uuid (foreign key to artifact_skills)
|
||||
- skill3_level: integer (1-15)
|
||||
- skill4_id: uuid (foreign key to artifact_skills, optional for standard artifacts)
|
||||
- skill4_level: integer (1-15, optional)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- index on user_id
|
||||
- index on artifact_id
|
||||
- index on [user_id, artifact_id]
|
||||
```
|
||||
|
||||
|
||||
#### grid_artifacts (Party Equipment)
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- party_id: uuid (foreign key to parties, not null)
|
||||
- grid_character_id: uuid (foreign key to grid_characters, not null)
|
||||
- collection_artifact_id: uuid (foreign key to collection_artifacts, optional)
|
||||
|
||||
# Quick-build fields (when not using collection)
|
||||
- artifact_id: uuid (foreign key to artifacts, optional)
|
||||
- level: integer (1-200)
|
||||
- skill1_id: uuid (foreign key to artifact_skills)
|
||||
- skill1_level: integer (1-15)
|
||||
- skill2_id: uuid (foreign key to artifact_skills)
|
||||
- skill2_level: integer (1-15)
|
||||
- skill3_id: uuid (foreign key to artifact_skills)
|
||||
- skill3_level: integer (1-15)
|
||||
- skill4_id: uuid (foreign key to artifact_skills, optional)
|
||||
- skill4_level: integer (1-15, optional)
|
||||
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on grid_character_id (one artifact per character)
|
||||
- index on party_id
|
||||
- index on collection_artifact_id
|
||||
- index on artifact_id
|
||||
```
|
||||
|
||||
|
||||
### Model Relationships
|
||||
|
||||
```ruby
|
||||
# User model additions
|
||||
has_many :collection_artifacts, dependent: :destroy
|
||||
|
||||
# Artifact model (canonical game data)
|
||||
class Artifact < ApplicationRecord
|
||||
has_many :collection_artifacts, dependent: :restrict_with_error
|
||||
has_many :grid_artifacts, dependent: :restrict_with_error
|
||||
|
||||
validates :name_en, :name_jp, presence: true
|
||||
validates :rarity, inclusion: { in: 3..5 }
|
||||
|
||||
scope :standard, -> { where(is_quirk: false) }
|
||||
scope :quirk, -> { where(is_quirk: true) }
|
||||
|
||||
def max_level
|
||||
is_quirk ? 200 : 150
|
||||
end
|
||||
|
||||
def max_skill_slots
|
||||
is_quirk ? 4 : 3
|
||||
end
|
||||
end
|
||||
|
||||
# ArtifactSkill model (canonical game data)
|
||||
class ArtifactSkill < ApplicationRecord
|
||||
validates :name_en, :name_jp, presence: true
|
||||
validates :skill_group, inclusion: { in: 1..3 }
|
||||
validates :max_level, presence: true
|
||||
|
||||
scope :group_i, -> { where(skill_group: 1) }
|
||||
scope :group_ii, -> { where(skill_group: 2) }
|
||||
scope :group_iii, -> { where(skill_group: 3) }
|
||||
end
|
||||
|
||||
# CollectionArtifact model
|
||||
class CollectionArtifact < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :artifact
|
||||
belongs_to :skill1, class_name: 'ArtifactSkill', optional: true
|
||||
belongs_to :skill2, class_name: 'ArtifactSkill', optional: true
|
||||
belongs_to :skill3, class_name: 'ArtifactSkill', optional: true
|
||||
belongs_to :skill4, class_name: 'ArtifactSkill', optional: true
|
||||
|
||||
has_one :grid_artifact, dependent: :nullify
|
||||
|
||||
validates :level, numericality: {
|
||||
greater_than_or_equal_to: 1,
|
||||
less_than_or_equal_to: 200
|
||||
}
|
||||
validates :skill1_level, :skill2_level, :skill3_level,
|
||||
numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 15 },
|
||||
allow_nil: true
|
||||
validates :skill4_level,
|
||||
numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 15 },
|
||||
allow_nil: true
|
||||
|
||||
# Validate skill4 only exists for quirk artifacts
|
||||
validate :skill4_only_for_quirk
|
||||
|
||||
def skills
|
||||
[skill1, skill2, skill3, skill4].compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skill4_only_for_quirk
|
||||
if skill4_id.present? && artifact && !artifact.is_quirk
|
||||
errors.add(:skill4_id, "can only be set for quirk artifacts")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# GridArtifact model
|
||||
class GridArtifact < ApplicationRecord
|
||||
belongs_to :party
|
||||
belongs_to :grid_character
|
||||
belongs_to :collection_artifact, optional: true
|
||||
belongs_to :artifact, optional: true # For quick-build
|
||||
belongs_to :skill1, class_name: 'ArtifactSkill', optional: true
|
||||
belongs_to :skill2, class_name: 'ArtifactSkill', optional: true
|
||||
belongs_to :skill3, class_name: 'ArtifactSkill', optional: true
|
||||
belongs_to :skill4, class_name: 'ArtifactSkill', optional: true
|
||||
|
||||
validates :grid_character_id, uniqueness: true
|
||||
validate :validate_artifact_source
|
||||
|
||||
def from_collection?
|
||||
collection_artifact_id.present?
|
||||
end
|
||||
|
||||
def artifact_details
|
||||
if from_collection?
|
||||
collection_artifact.artifact
|
||||
else
|
||||
artifact
|
||||
end
|
||||
end
|
||||
|
||||
def skills
|
||||
if from_collection?
|
||||
collection_artifact.skills
|
||||
else
|
||||
[skill1, skill2, skill3, skill4].compact
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_artifact_source
|
||||
if collection_artifact_id.blank? && artifact_id.blank?
|
||||
errors.add(:base, "Must specify either collection artifact or quick-build artifact")
|
||||
end
|
||||
|
||||
if collection_artifact_id.present? && artifact_id.present? && !from_collection?
|
||||
errors.add(:base, "Cannot specify both collection and quick-build artifact")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# GridCharacter model additions
|
||||
has_one :grid_artifact, dependent: :destroy
|
||||
|
||||
# Party model additions
|
||||
has_many :grid_artifacts, dependent: :destroy
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
#### Endpoints
|
||||
|
||||
##### Collection Artifacts
|
||||
```
|
||||
GET /api/v1/collection/artifacts
|
||||
Query params: page, limit, artifact_id (filter)
|
||||
Response: Paginated list of user's artifacts
|
||||
|
||||
GET /api/v1/collection/artifacts/:id
|
||||
Response: Single collection artifact details
|
||||
|
||||
POST /api/v1/collection/artifacts
|
||||
Body: artifact_id, level, skill1_id, skill1_level, skill2_id, skill2_level, etc.
|
||||
Response: Created collection artifact
|
||||
|
||||
PUT /api/v1/collection/artifacts/:id
|
||||
Body: Updated fields
|
||||
Response: Updated collection artifact
|
||||
|
||||
DELETE /api/v1/collection/artifacts/:id
|
||||
Response: Success/error status
|
||||
```
|
||||
|
||||
##### Grid Artifacts (Party Equipment)
|
||||
```
|
||||
GET /api/v1/parties/:party_id/grid_artifacts
|
||||
Response: List of artifacts equipped in party
|
||||
|
||||
POST /api/v1/parties/:party_id/grid_artifacts
|
||||
Body: { grid_character_id, collection_artifact_id } OR
|
||||
{ grid_character_id, artifact_id, level, skills... } (quick-build)
|
||||
Response: Created grid artifact
|
||||
|
||||
PUT /api/v1/parties/:party_id/grid_artifacts/:id
|
||||
Body: Updated artifact reference or properties
|
||||
Response: Updated grid artifact
|
||||
|
||||
DELETE /api/v1/parties/:party_id/grid_artifacts/:id
|
||||
Response: Success/error status
|
||||
```
|
||||
|
||||
##### Canonical Data Endpoints
|
||||
```
|
||||
GET /api/v1/artifacts
|
||||
Query: is_quirk?, page, limit
|
||||
Response: List of all artifacts
|
||||
|
||||
GET /api/v1/artifacts/:id
|
||||
Response: Artifact details
|
||||
|
||||
GET /api/v1/artifact_skills
|
||||
Query: skill_group?, page, limit
|
||||
Response: List of all artifact skills
|
||||
|
||||
GET /api/v1/artifact_skills/:id
|
||||
Response: Skill details
|
||||
```
|
||||
|
||||
##### Collection Management
|
||||
```
|
||||
GET /api/v1/users/:user_id/collection/artifacts
|
||||
Response: View another user's artifact collection (respects privacy settings)
|
||||
|
||||
GET /api/v1/collection/statistics
|
||||
Response: {
|
||||
total_artifacts: 50,
|
||||
breakdown_by_rarity: {standard: 45, quirk: 5},
|
||||
breakdown_by_level: {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Authorization**: Collection management endpoints require authentication
|
||||
2. **Ownership**: Users can only modify their own collection
|
||||
3. **Privacy Controls**: Respect user's collection_privacy settings when viewing collections
|
||||
4. **Validation**: Strict validation of skill combinations and levels
|
||||
5. **Rate Limiting**: Standard rate limiting on all collection endpoints
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Eager Loading**: Include skills and artifact data in collection queries
|
||||
2. **Batch Operations**: Support bulk artifact operations for imports
|
||||
3. **Indexed Queries**: Proper indexes on frequently filtered columns
|
||||
4. **Pagination**: Mandatory pagination for collection endpoints
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Models and Database
|
||||
- Create migrations for artifacts, skills, and collections
|
||||
- Implement Artifact and ArtifactSkill models
|
||||
- Implement CollectionArtifact model
|
||||
- Seed canonical artifact and skill data
|
||||
|
||||
### Phase 2: API Controllers and Blueprints
|
||||
- Implement collection artifacts CRUD controller
|
||||
- Create artifact blueprints
|
||||
- Add authentication and authorization
|
||||
- Write controller specs
|
||||
|
||||
### Phase 3: Grid Integration
|
||||
- Implement GridArtifact model
|
||||
- Create grid artifacts controller
|
||||
- Add artifact support to party endpoints
|
||||
- Integrate with existing party system
|
||||
|
||||
### Phase 4: Frontend Integration
|
||||
- Update frontend models and types
|
||||
- Create artifact management UI
|
||||
- Add artifact selection in party builder
|
||||
- Implement collection views
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Performance**: All endpoints respond within 200ms for standard operations
|
||||
2. **Reliability**: 99.9% uptime for artifact services
|
||||
3. **User Adoption**: 50% of active users use artifact tracking within 3 months
|
||||
4. **Data Integrity**: Zero data loss incidents
|
||||
356
docs/planning/collection-tracking-plan.md
Normal file
356
docs/planning/collection-tracking-plan.md
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
# Collection Tracking Feature Plan
|
||||
|
||||
## Overview
|
||||
|
||||
The Collection Tracking feature enables users to maintain a comprehensive inventory of their Granblue Fantasy game items. This system is distinct from the existing grid/party system and focuses on cataloging what items users own rather than how they use them in team compositions.
|
||||
|
||||
## Business Requirements
|
||||
|
||||
### User Stories
|
||||
|
||||
1. **As a user**, I want to record which characters I own so I can keep track of my collection progress.
|
||||
2. **As a user**, I want to save character customizations (rings, awakenings, transcendence) so I can reference them when building teams.
|
||||
3. **As a user**, I want to track multiple copies of the same weapon/summon with different properties so I can manage my duplicates.
|
||||
4. **As a user**, I want to record my job accessories collection so I know which ones I still need to obtain.
|
||||
5. **As a user**, I want to import/export my collection data so I can backup or share my inventory.
|
||||
6. **As a user**, I want to see statistics about my collection so I can track my progress.
|
||||
|
||||
### Core Concepts
|
||||
|
||||
#### Collection vs Grid Items
|
||||
- **Grid Items** (GridCharacter, GridWeapon, GridSummon): Represent items configured within a specific party/team
|
||||
- **Collection Items**: Represent the user's overall inventory, independent of any party configuration
|
||||
|
||||
#### Item Uniqueness Rules
|
||||
- **Characters**: One instance per character (by granblue_id) per user
|
||||
- **Weapons**: Multiple instances of the same weapon allowed, each with unique properties
|
||||
- **Summons**: Multiple instances of the same summon allowed, each with unique properties
|
||||
- **Job Accessories**: One instance per accessory (by granblue_id) per user
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Database Schema
|
||||
|
||||
#### users table additions
|
||||
```sql
|
||||
- collection_privacy: integer (default: 0, not null)
|
||||
# 0 = public (viewable by everyone)
|
||||
# 1 = crew_only (viewable by crew members only)
|
||||
# 2 = private (viewable by owner only)
|
||||
|
||||
Index:
|
||||
- index on collection_privacy
|
||||
```
|
||||
|
||||
#### collection_characters
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- user_id: uuid (foreign key to users)
|
||||
- character_id: uuid (foreign key to characters)
|
||||
- uncap_level: integer (0-5)
|
||||
- transcendence_step: integer (0-10)
|
||||
- perpetuity: boolean
|
||||
- awakening_id: uuid (foreign key to awakenings, optional)
|
||||
- awakening_level: integer (1-10)
|
||||
- ring1: jsonb {modifier: integer, strength: float}
|
||||
- ring2: jsonb {modifier: integer, strength: float}
|
||||
- ring3: jsonb {modifier: integer, strength: float}
|
||||
- ring4: jsonb {modifier: integer, strength: float}
|
||||
- earring: jsonb {modifier: integer, strength: float}
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on [user_id, character_id]
|
||||
- index on user_id
|
||||
- index on character_id
|
||||
```
|
||||
|
||||
#### collection_weapons
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- user_id: uuid (foreign key to users)
|
||||
- weapon_id: uuid (foreign key to weapons)
|
||||
- uncap_level: integer (0-5)
|
||||
- transcendence_step: integer (0-10)
|
||||
- weapon_key1_id: uuid (foreign key to weapon_keys, optional)
|
||||
- weapon_key2_id: uuid (foreign key to weapon_keys, optional)
|
||||
- weapon_key3_id: uuid (foreign key to weapon_keys, optional)
|
||||
- weapon_key4_id: uuid (foreign key to weapon_keys, optional)
|
||||
- awakening_id: uuid (foreign key to awakenings, optional)
|
||||
- awakening_level: integer (1-10)
|
||||
- ax_modifier1: integer
|
||||
- ax_strength1: float
|
||||
- ax_modifier2: integer
|
||||
- ax_strength2: float
|
||||
- element: integer (for element-changeable weapons)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- index on user_id
|
||||
- index on weapon_id
|
||||
- index on [user_id, weapon_id]
|
||||
```
|
||||
|
||||
#### collection_summons
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- user_id: uuid (foreign key to users)
|
||||
- summon_id: uuid (foreign key to summons)
|
||||
- uncap_level: integer (0-5)
|
||||
- transcendence_step: integer (0-10)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- index on user_id
|
||||
- index on summon_id
|
||||
- index on [user_id, summon_id]
|
||||
```
|
||||
|
||||
#### collection_job_accessories
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- user_id: uuid (foreign key to users)
|
||||
- job_accessory_id: uuid (foreign key to job_accessories)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on [user_id, job_accessory_id]
|
||||
- index on user_id
|
||||
- index on job_accessory_id
|
||||
```
|
||||
|
||||
### Model Relationships
|
||||
|
||||
```ruby
|
||||
# User model additions
|
||||
has_many :collection_characters, dependent: :destroy
|
||||
has_many :collection_weapons, dependent: :destroy
|
||||
has_many :collection_summons, dependent: :destroy
|
||||
has_many :collection_job_accessories, dependent: :destroy
|
||||
|
||||
# CollectionCharacter
|
||||
belongs_to :user
|
||||
belongs_to :character
|
||||
belongs_to :awakening, optional: true
|
||||
validates :character_id, uniqueness: { scope: :user_id }
|
||||
|
||||
# CollectionWeapon
|
||||
belongs_to :user
|
||||
belongs_to :weapon
|
||||
belongs_to :awakening, optional: true
|
||||
belongs_to :weapon_key1, class_name: 'WeaponKey', optional: true
|
||||
belongs_to :weapon_key2, class_name: 'WeaponKey', optional: true
|
||||
belongs_to :weapon_key3, class_name: 'WeaponKey', optional: true
|
||||
belongs_to :weapon_key4, class_name: 'WeaponKey', optional: true
|
||||
|
||||
# CollectionSummon
|
||||
belongs_to :user
|
||||
belongs_to :summon
|
||||
|
||||
# CollectionJobAccessory
|
||||
belongs_to :user
|
||||
belongs_to :job_accessory
|
||||
validates :job_accessory_id, uniqueness: { scope: :user_id }
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
#### Endpoints
|
||||
|
||||
##### Collection Characters
|
||||
```
|
||||
GET /api/v1/collection/characters
|
||||
Query params: page, limit
|
||||
Response: Paginated list of user's characters
|
||||
|
||||
GET /api/v1/collection/characters/:id
|
||||
Response: Single collection character details
|
||||
|
||||
POST /api/v1/collection/characters
|
||||
Body: character_id, uncap_level, transcendence_step, rings, etc.
|
||||
Response: Created collection character
|
||||
|
||||
PUT /api/v1/collection/characters/:id
|
||||
Body: Updated fields
|
||||
Response: Updated collection character
|
||||
|
||||
DELETE /api/v1/collection/characters/:id
|
||||
Response: Success/error status
|
||||
```
|
||||
|
||||
##### Collection Weapons
|
||||
```
|
||||
GET /api/v1/collection/weapons
|
||||
Query params: page, limit, weapon_id (filter)
|
||||
Response: Paginated list of user's weapons
|
||||
|
||||
GET /api/v1/collection/weapons/:id
|
||||
Response: Single collection weapon details
|
||||
|
||||
POST /api/v1/collection/weapons
|
||||
Body: weapon_id, uncap_level, keys, ax_modifiers, etc.
|
||||
Response: Created collection weapon
|
||||
|
||||
PUT /api/v1/collection/weapons/:id
|
||||
Body: Updated fields
|
||||
Response: Updated collection weapon
|
||||
|
||||
DELETE /api/v1/collection/weapons/:id
|
||||
Response: Success/error status
|
||||
```
|
||||
|
||||
##### Collection Summons
|
||||
```
|
||||
GET /api/v1/collection/summons
|
||||
Query params: page, limit, summon_id (filter)
|
||||
Response: Paginated list of user's summons
|
||||
|
||||
GET /api/v1/collection/summons/:id
|
||||
Response: Single collection summon details
|
||||
|
||||
POST /api/v1/collection/summons
|
||||
Body: summon_id, uncap_level, transcendence_step
|
||||
Response: Created collection summon
|
||||
|
||||
PUT /api/v1/collection/summons/:id
|
||||
Body: Updated fields
|
||||
Response: Updated collection summon
|
||||
|
||||
DELETE /api/v1/collection/summons/:id
|
||||
Response: Success/error status
|
||||
```
|
||||
|
||||
##### Collection Job Accessories
|
||||
```
|
||||
GET /api/v1/collection/job_accessories
|
||||
Query params: page, limit, job_id (filter)
|
||||
Response: Paginated list of user's job accessories
|
||||
|
||||
POST /api/v1/collection/job_accessories
|
||||
Body: job_accessory_id
|
||||
Response: Created collection job accessory
|
||||
|
||||
DELETE /api/v1/collection/job_accessories/:id
|
||||
Response: Success/error status
|
||||
```
|
||||
|
||||
##### Collection Management
|
||||
```
|
||||
GET /api/v1/collection/statistics
|
||||
Response: {
|
||||
total_characters: 150,
|
||||
total_weapons: 500,
|
||||
total_summons: 200,
|
||||
total_job_accessories: 25,
|
||||
breakdown_by_element: {...},
|
||||
breakdown_by_rarity: {...}
|
||||
}
|
||||
|
||||
POST /api/v1/collection/import
|
||||
Body: JSON with collection data
|
||||
Response: Import results
|
||||
|
||||
GET /api/v1/collection/export
|
||||
Response: Complete collection data as JSON
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
#### Custom Error Classes
|
||||
|
||||
```ruby
|
||||
# app/errors/collection_errors.rb
|
||||
module CollectionErrors
|
||||
class CollectionItemNotFound < StandardError; end
|
||||
class DuplicateCharacter < StandardError; end
|
||||
class DuplicateJobAccessory < StandardError; end
|
||||
class InvalidWeaponKey < StandardError; end
|
||||
class InvalidAwakening < StandardError; end
|
||||
class InvalidUncapLevel < StandardError; end
|
||||
class InvalidTranscendenceStep < StandardError; end
|
||||
class CollectionLimitExceeded < StandardError; end
|
||||
end
|
||||
```
|
||||
|
||||
#### Error Responses
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "DuplicateCharacter",
|
||||
"message": "Character already exists in collection",
|
||||
"details": {
|
||||
"character_id": "3040001000",
|
||||
"existing_id": "uuid-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Authorization**: Collection management endpoints require authentication
|
||||
2. **Ownership**: Users can only modify their own collection
|
||||
3. **Privacy Controls**: Three-tier privacy system:
|
||||
- **Public**: Viewable by everyone
|
||||
- **Crew Only**: Viewable by crew members (future feature)
|
||||
- **Private**: Viewable only by the owner
|
||||
4. **Access Control**: Enforced based on privacy level and viewer relationship
|
||||
5. **Rate Limiting**: Import/export endpoints have stricter rate limits
|
||||
6. **Validation**: Strict validation of all input data against game rules
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Eager Loading**: Use includes() to avoid N+1 queries
|
||||
2. **Pagination**: All list endpoints must be paginated
|
||||
3. **Caching**: Cache statistics and export data (15-minute TTL)
|
||||
4. **Batch Operations**: Support bulk create/update for imports
|
||||
5. **Database Indexes**: Proper indexes on foreign keys and common query patterns
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Models and Database (Week 1)
|
||||
- Create migrations for all collection tables
|
||||
- Implement collection models with validations
|
||||
- Add model associations and scopes
|
||||
- Write model specs
|
||||
|
||||
### Phase 2: API Controllers and Blueprints (Week 2)
|
||||
- Implement CRUD controllers for each collection type
|
||||
- Create blueprint serializers
|
||||
- Add authentication and authorization
|
||||
- Write controller specs
|
||||
|
||||
### Phase 3: Import/Export and Statistics (Week 3)
|
||||
- Implement bulk import functionality
|
||||
- Add export endpoints
|
||||
- Create statistics aggregation
|
||||
- Add background job support for large imports
|
||||
|
||||
### Phase 4: Frontend Integration (Week 4)
|
||||
- Update frontend models and types
|
||||
- Create collection management UI
|
||||
- Add import/export interface
|
||||
- Implement collection statistics dashboard
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Performance**: All endpoints respond within 200ms for standard operations
|
||||
2. **Reliability**: 99.9% uptime for collection services
|
||||
3. **User Adoption**: 50% of active users use collection tracking within 3 months
|
||||
4. **Data Integrity**: Zero data loss incidents
|
||||
5. **User Satisfaction**: 4+ star rating for the feature
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Collection Sharing**: Allow users to share their collection publicly
|
||||
2. **Collection Goals**: Set targets for collection completion
|
||||
3. **Collection Comparison**: Compare collections between users
|
||||
4. **Automated Sync**: Sync with game data via API (if available)
|
||||
5. **Collection Value**: Calculate total collection value/rarity score
|
||||
6. **Mobile App**: Native mobile app for collection management
|
||||
7. **Collection History**: Track changes to collection over time
|
||||
458
docs/planning/crew-feature-plan.md
Normal file
458
docs/planning/crew-feature-plan.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# Crew Feature Plan
|
||||
|
||||
## Overview
|
||||
|
||||
The Crew system enables players to form groups of up to 30 members to collaborate, share strategies, and compete in Unite and Fight events. Crews provide a social layer to the application with hierarchical roles, shared content visibility, and performance tracking capabilities.
|
||||
|
||||
## Business Requirements
|
||||
|
||||
### Core Concepts
|
||||
|
||||
#### Crew Structure
|
||||
- **Size Limit**: Maximum 30 members per crew
|
||||
- **Roles Hierarchy**:
|
||||
- **Captain**: The crew creator with full administrative privileges
|
||||
- **Subcaptains**: Up to 3 members with elevated permissions
|
||||
- **Members**: Regular crew participants with standard access
|
||||
|
||||
#### Key Features
|
||||
1. **Crew Management**: Creation, invitation, membership control
|
||||
2. **Gamertags**: 4-character tags displayed alongside member names
|
||||
3. **Unite and Fight**: Event participation and score tracking
|
||||
4. **Crew Feed**: Private content stream for crew-only parties, guides, and future posts
|
||||
5. **Performance Analytics**: Historical tracking and visualization of member contributions
|
||||
|
||||
### User Stories
|
||||
|
||||
#### As a Captain
|
||||
- I want to create a crew and invite players to join
|
||||
- I want to appoint up to 3 subcaptains to help manage the crew
|
||||
- I want to remove members who are inactive or problematic
|
||||
- I want to set crew rules that all members can view
|
||||
- I want to track member performance in Unite and Fight events
|
||||
|
||||
#### As a Subcaptain
|
||||
- I want to help manage crew by inviting new members
|
||||
- I want to update crew rules and information
|
||||
- I want to record Unite and Fight scores for members
|
||||
- I want to set gamertags for crew representation
|
||||
|
||||
#### As a Member
|
||||
- I want to join a crew via invitation link
|
||||
- I want to view crew rules and information
|
||||
- I want to see my Unite and Fight performance history
|
||||
- I want to choose whether to display my crew's gamertag
|
||||
- I want to see crew-only parties and guides in the feed
|
||||
|
||||
#### As a System Administrator
|
||||
- I want to add new Unite and Fight events when announced by Cygames
|
||||
- I want to manage event dates and parameters
|
||||
- I want to monitor crew activities for policy violations
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Database Schema
|
||||
|
||||
#### crews
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- name: string (unique, not null)
|
||||
- captain_id: uuid (foreign key to users, not null)
|
||||
- gamertag: string (4 characters, unique, can be null)
|
||||
- rules: text
|
||||
- member_count: integer (counter cache, default 1)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on name
|
||||
- unique index on gamertag (where not null)
|
||||
- index on captain_id
|
||||
- index on created_at
|
||||
```
|
||||
|
||||
#### crew_memberships
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- crew_id: uuid (foreign key to crews, not null)
|
||||
- user_id: uuid (foreign key to users, not null)
|
||||
- role: integer (0=member, 1=subcaptain, 2=captain)
|
||||
- display_gamertag: boolean (default true)
|
||||
- joined_at: timestamp (default now)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on [crew_id, user_id]
|
||||
- index on crew_id
|
||||
- index on user_id
|
||||
- index on role
|
||||
- index on joined_at
|
||||
```
|
||||
|
||||
#### crew_invitations
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- crew_id: uuid (foreign key to crews, not null)
|
||||
- invited_by_id: uuid (foreign key to users, not null)
|
||||
- token: string (unique, not null)
|
||||
- expires_at: timestamp (default 7 days from creation)
|
||||
- used_at: timestamp (null)
|
||||
- used_by_id: uuid (foreign key to users, null)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on token
|
||||
- index on crew_id
|
||||
- index on expires_at
|
||||
- index on [crew_id, used_at] (for tracking active invitations)
|
||||
```
|
||||
|
||||
#### unite_and_fights
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- name: string (not null)
|
||||
- event_number: integer (sequential, unique)
|
||||
- starts_at: timestamp (not null)
|
||||
- ends_at: timestamp (not null)
|
||||
- created_by_id: uuid (foreign key to users, not null)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on event_number
|
||||
- index on starts_at
|
||||
- index on ends_at
|
||||
- index on [starts_at, ends_at] (for finding active events)
|
||||
```
|
||||
|
||||
#### unf_scores
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- unite_and_fight_id: uuid (foreign key to unite_and_fights, not null)
|
||||
- crew_id: uuid (foreign key to crews, not null)
|
||||
- user_id: uuid (foreign key to users, not null)
|
||||
- honors: bigint (not null, default 0)
|
||||
- recorded_by_id: uuid (foreign key to users, not null)
|
||||
- day_number: integer (1-7, not null)
|
||||
- created_at: timestamp
|
||||
- updated_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- unique index on [unite_and_fight_id, crew_id, user_id, day_number]
|
||||
- index on unite_and_fight_id
|
||||
- index on crew_id
|
||||
- index on user_id
|
||||
- index on [crew_id, unite_and_fight_id] (for crew performance queries)
|
||||
- index on honors (for rankings)
|
||||
```
|
||||
|
||||
#### crew_feeds (future table for reference)
|
||||
```sql
|
||||
- id: uuid (primary key)
|
||||
- crew_id: uuid (foreign key to crews, not null)
|
||||
- feedable_type: string (Party, Guide, Post, etc.)
|
||||
- feedable_id: uuid (polymorphic reference)
|
||||
- created_at: timestamp
|
||||
|
||||
Indexes:
|
||||
- index on [crew_id, created_at] (for feed queries)
|
||||
- index on [feedable_type, feedable_id]
|
||||
```
|
||||
|
||||
### Model Relationships
|
||||
|
||||
```ruby
|
||||
# User model additions
|
||||
has_one :crew_membership, dependent: :destroy
|
||||
has_one :crew, through: :crew_membership
|
||||
has_many :captained_crews, class_name: 'Crew', foreign_key: :captain_id
|
||||
has_many :crew_invitations_sent, class_name: 'CrewInvitation', foreign_key: :invited_by_id
|
||||
has_many :unf_scores
|
||||
has_many :recorded_unf_scores, class_name: 'UnfScore', foreign_key: :recorded_by_id
|
||||
|
||||
# Crew model
|
||||
belongs_to :captain, class_name: 'User'
|
||||
has_many :crew_memberships, dependent: :destroy
|
||||
has_many :members, through: :crew_memberships, source: :user
|
||||
has_many :crew_invitations, dependent: :destroy
|
||||
has_many :unf_scores, dependent: :destroy
|
||||
has_many :subcaptains, -> { where(crew_memberships: { role: 1 }) },
|
||||
through: :crew_memberships, source: :user
|
||||
|
||||
# CrewMembership model
|
||||
belongs_to :crew, counter_cache: :member_count
|
||||
belongs_to :user
|
||||
enum role: { member: 0, subcaptain: 1, captain: 2 }
|
||||
|
||||
# CrewInvitation model
|
||||
belongs_to :crew
|
||||
belongs_to :invited_by, class_name: 'User'
|
||||
belongs_to :used_by, class_name: 'User', optional: true
|
||||
|
||||
# UniteAndFight model
|
||||
has_many :unf_scores, dependent: :destroy
|
||||
belongs_to :created_by, class_name: 'User'
|
||||
|
||||
# UnfScore model
|
||||
belongs_to :unite_and_fight
|
||||
belongs_to :crew
|
||||
belongs_to :user
|
||||
belongs_to :recorded_by, class_name: 'User'
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
#### Crew Management
|
||||
|
||||
##### Crew CRUD
|
||||
```
|
||||
POST /api/v1/crews
|
||||
Body: { name, rules?, gamertag? }
|
||||
Response: Created crew with captain membership
|
||||
|
||||
GET /api/v1/crews/:id
|
||||
Response: Crew details with members list
|
||||
|
||||
PUT /api/v1/crews/:id
|
||||
Body: { name?, rules?, gamertag? }
|
||||
Response: Updated crew (captain/subcaptain only)
|
||||
|
||||
DELETE /api/v1/crews/:id
|
||||
Response: Success (captain only, disbands crew)
|
||||
|
||||
GET /api/v1/crews/my
|
||||
Response: Current user's crew with full details
|
||||
```
|
||||
|
||||
##### Member Management
|
||||
```
|
||||
GET /api/v1/crews/:id/members
|
||||
Response: Paginated list of crew members with roles
|
||||
|
||||
POST /api/v1/crews/:id/members/promote
|
||||
Body: { user_id, role: "subcaptain" }
|
||||
Response: Updated membership (captain only)
|
||||
|
||||
DELETE /api/v1/crews/:id/members/:user_id
|
||||
Response: Success (captain only)
|
||||
|
||||
PUT /api/v1/crews/:id/members/me
|
||||
Body: { display_gamertag }
|
||||
Response: Updated own membership settings
|
||||
```
|
||||
|
||||
##### Invitations
|
||||
```
|
||||
POST /api/v1/crews/:id/invitations
|
||||
Response: { invitation_url, token, expires_at }
|
||||
Note: Captain/subcaptain only
|
||||
|
||||
GET /api/v1/crews/:id/invitations
|
||||
Response: List of pending invitations (captain/subcaptain)
|
||||
|
||||
DELETE /api/v1/crews/:id/invitations/:id
|
||||
Response: Revoke invitation (captain/subcaptain)
|
||||
|
||||
POST /api/v1/crews/join
|
||||
Body: { token }
|
||||
Response: Joined crew details
|
||||
```
|
||||
|
||||
#### Unite and Fight
|
||||
|
||||
##### Event Management (Admin)
|
||||
```
|
||||
GET /api/v1/unite_and_fights
|
||||
Response: List of all UnF events
|
||||
|
||||
POST /api/v1/unite_and_fights
|
||||
Body: { name, event_number, starts_at, ends_at }
|
||||
Response: Created event (requires level 7+ permissions)
|
||||
|
||||
PUT /api/v1/unite_and_fights/:id
|
||||
Body: { name?, starts_at?, ends_at? }
|
||||
Response: Updated event (admin only)
|
||||
```
|
||||
|
||||
##### Score Management
|
||||
```
|
||||
POST /api/v1/unf_scores
|
||||
Body: { unite_and_fight_id, user_id, honors, day_number }
|
||||
Response: Created/updated score (captain/subcaptain only)
|
||||
|
||||
GET /api/v1/crews/:crew_id/unf_scores
|
||||
Query: unite_and_fight_id?, user_id?
|
||||
Response: Scores for crew, optionally filtered
|
||||
|
||||
GET /api/v1/unf_scores/performance
|
||||
Query: crew_id, user_id?, from_date?, to_date?
|
||||
Response: Performance data for graphing
|
||||
```
|
||||
|
||||
#### Crew Feed
|
||||
```
|
||||
GET /api/v1/crews/:id/feed
|
||||
Query: page, limit, type?
|
||||
Response: Paginated feed of crew-only content
|
||||
```
|
||||
|
||||
### Authorization & Permissions
|
||||
|
||||
#### Permission Matrix
|
||||
|
||||
| Action | Captain | Subcaptain | Member | Non-member |
|
||||
|--------|---------|------------|---------|------------|
|
||||
| View crew info | ✓ | ✓ | ✓ | ✓ |
|
||||
| View member list | ✓ | ✓ | ✓ | ✗ |
|
||||
| Update crew info | ✓ | ✓ | ✗ | ✗ |
|
||||
| Set gamertag | ✓ | ✓ | ✗ | ✗ |
|
||||
| Invite members | ✓ | ✓ | ✗ | ✗ |
|
||||
| Remove members | ✓ | ✗ | ✗ | ✗ |
|
||||
| Promote to subcaptain | ✓ | ✗ | ✗ | ✗ |
|
||||
| Record UnF scores | ✓ | ✓ | ✗ | ✗ |
|
||||
| View UnF scores | ✓ | ✓ | ✓ | ✗ |
|
||||
| View crew feed | ✓ | ✓ | ✓ | ✗ |
|
||||
| Leave crew | ✗* | ✓ | ✓ | ✗ |
|
||||
|
||||
*Captain must transfer ownership or disband crew
|
||||
|
||||
#### Authorization Helpers
|
||||
|
||||
```ruby
|
||||
# app/models/user.rb
|
||||
def captain_of?(crew)
|
||||
crew.captain_id == id
|
||||
end
|
||||
|
||||
def subcaptain_of?(crew)
|
||||
crew_membership&.subcaptain? && crew_membership.crew_id == crew.id
|
||||
end
|
||||
|
||||
def member_of?(crew)
|
||||
crew_membership&.crew_id == crew.id
|
||||
end
|
||||
|
||||
def can_manage_crew?(crew)
|
||||
captain_of?(crew) || subcaptain_of?(crew)
|
||||
end
|
||||
|
||||
def can_invite_to_crew?(crew)
|
||||
can_manage_crew?(crew)
|
||||
end
|
||||
|
||||
def can_remove_from_crew?(crew)
|
||||
captain_of?(crew)
|
||||
end
|
||||
|
||||
def can_record_unf_scores?(crew)
|
||||
can_manage_crew?(crew)
|
||||
end
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Invitation Security**:
|
||||
- Tokens are cryptographically secure random strings
|
||||
- Automatic expiration after 7 days
|
||||
- One-time use only
|
||||
- Rate limiting on join attempts
|
||||
|
||||
2. **Member Limits**:
|
||||
- Enforce 30 member maximum at database level
|
||||
- Check before invitation acceptance
|
||||
- Atomic operations for membership changes
|
||||
|
||||
3. **Role Management**:
|
||||
- Only captain can promote/demote
|
||||
- Maximum 3 subcaptains enforced
|
||||
- Captain role transfer requires explicit action
|
||||
|
||||
4. **Data Privacy**:
|
||||
- Crew-only content respects visibility settings
|
||||
- UnF scores only visible to crew members
|
||||
- Member list public, but details restricted
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Caching**:
|
||||
- Cache crew member lists (5-minute TTL)
|
||||
- Cache UnF leaderboards (1-hour TTL)
|
||||
- Cache crew feed content
|
||||
|
||||
2. **Database Optimization**:
|
||||
- Counter cache for member_count
|
||||
- Composite indexes for common queries
|
||||
- Partial indexes for active records
|
||||
|
||||
3. **Query Optimization**:
|
||||
- Eager load associations
|
||||
- Pagination for member lists and feeds
|
||||
- Batch operations for UnF score updates
|
||||
|
||||
4. **Background Jobs**:
|
||||
- Async invitation email sending
|
||||
- Scheduled cleanup of expired invitations
|
||||
- UnF score aggregation calculations
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Crew System (Week 1-2)
|
||||
- Database migrations and models
|
||||
- Basic CRUD operations
|
||||
- Captain and member roles
|
||||
- Invitation system
|
||||
|
||||
### Phase 2: Advanced Roles & Permissions (Week 3)
|
||||
- Subcaptain functionality
|
||||
- Permission system
|
||||
- Gamertag management
|
||||
- Crew rules
|
||||
|
||||
### Phase 3: Unite and Fight Integration (Week 4-5)
|
||||
- UnF event management
|
||||
- Score recording system
|
||||
- Performance queries
|
||||
- Basic reporting
|
||||
|
||||
### Phase 4: Feed & Analytics (Week 6)
|
||||
- Crew feed implementation
|
||||
- Integration with parties/guides
|
||||
- Performance graphs
|
||||
- Historical tracking
|
||||
|
||||
### Phase 5: Polish & Optimization (Week 7)
|
||||
- Performance tuning
|
||||
- Caching layer
|
||||
- Background jobs
|
||||
- Admin tools
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Adoption**: 60% of active users join a crew within 3 months
|
||||
2. **Engagement**: Average crew has 15+ active members
|
||||
3. **Performance**: All crew operations complete within 200ms
|
||||
4. **Reliability**: 99.9% uptime for crew services
|
||||
5. **UnF Participation**: 80% score recording during events
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Crew Battles**: Inter-crew competitions outside UnF
|
||||
2. **Crew Chat**: Real-time messaging system
|
||||
3. **Crew Achievements**: Badges and milestones
|
||||
4. **Crew Resources**: Shared guides and strategies library
|
||||
5. **Crew Recruitment**: Public crew discovery and application system
|
||||
6. **Officer Roles**: Additional permission tiers
|
||||
7. **Crew Alliances**: Multi-crew coordination
|
||||
8. **Automated Scoring**: API integration with game data (if available)
|
||||
9. **Mobile Notifications**: Push notifications for crew events
|
||||
10. **Crew Statistics**: Advanced analytics and insights
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
1. **Toxic Behavior**: Implement reporting system and moderation tools
|
||||
2. **Inactive Crews**: Automatic leadership transfer after inactivity
|
||||
3. **Database Load**: Implement read replicas for heavy queries
|
||||
4. **Invitation Spam**: Rate limiting and abuse detection
|
||||
5. **Score Manipulation**: Audit logs and validation rules
|
||||
592
docs/rake-tasks.md
Normal file
592
docs/rake-tasks.md
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
# Rake Tasks Documentation
|
||||
|
||||
Complete reference for all available rake tasks in the hensei-api project. These tasks handle data management, image downloading, wiki synchronization, and various maintenance operations.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# List all available tasks
|
||||
rake -T
|
||||
|
||||
# List Granblue-specific tasks
|
||||
rake -T granblue
|
||||
|
||||
# List export tasks
|
||||
rake -T export
|
||||
|
||||
# Get detailed help for a task
|
||||
rake -D granblue:import_data
|
||||
```
|
||||
|
||||
## Image Download Tasks
|
||||
|
||||
### Character Images
|
||||
|
||||
#### `granblue:export:character_images`
|
||||
Download character images with all variants and sizes.
|
||||
|
||||
```bash
|
||||
# Download all characters
|
||||
rake granblue:export:character_images
|
||||
|
||||
# Download specific character
|
||||
rake granblue:export:character_images[3040001000]
|
||||
|
||||
# With options: [id,test_mode,verbose,storage,size]
|
||||
rake granblue:export:character_images[3040001000,false,true,s3,grid]
|
||||
|
||||
# Test mode (simulate)
|
||||
rake granblue:export:character_images[,true,true]
|
||||
|
||||
# Download only specific size for all
|
||||
rake granblue:export:character_images[,false,true,both,detail]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `id`: Granblue ID or empty for all
|
||||
- `test_mode`: true/false (default: false)
|
||||
- `verbose`: true/false (default: true)
|
||||
- `storage`: local/s3/both (default: both)
|
||||
- `size`: main/grid/square/detail (default: all)
|
||||
|
||||
### Weapon Images
|
||||
|
||||
#### `granblue:export:weapon_images`
|
||||
Download weapon images including elemental variants.
|
||||
|
||||
```bash
|
||||
# Download all weapons
|
||||
rake granblue:export:weapon_images
|
||||
|
||||
# Download specific weapon
|
||||
rake granblue:export:weapon_images[1040001000]
|
||||
|
||||
# S3 only, grid size
|
||||
rake granblue:export:weapon_images[1040001000,false,true,s3,grid]
|
||||
```
|
||||
|
||||
**Parameters:** Same as character_images
|
||||
|
||||
### Summon Images
|
||||
|
||||
#### `granblue:export:summon_images`
|
||||
Download summon images with uncap variants.
|
||||
|
||||
```bash
|
||||
# Download all summons
|
||||
rake granblue:export:summon_images
|
||||
|
||||
# Download specific summon
|
||||
rake granblue:export:summon_images[2040001000]
|
||||
|
||||
# Local storage only
|
||||
rake granblue:export:summon_images[2040001000,false,true,local]
|
||||
```
|
||||
|
||||
**Parameters:** Same as character_images
|
||||
|
||||
### Job Images
|
||||
|
||||
#### `granblue:export:job_images`
|
||||
Download job images with gender variants (wide and zoom formats).
|
||||
|
||||
```bash
|
||||
# Download all jobs
|
||||
rake granblue:export:job_images
|
||||
|
||||
# Download specific job
|
||||
rake granblue:export:job_images[100401]
|
||||
|
||||
# Download only zoom images
|
||||
rake granblue:export:job_images[100401,false,true,both,zoom]
|
||||
|
||||
# Download only wide images
|
||||
rake granblue:export:job_images[,false,true,both,wide]
|
||||
```
|
||||
|
||||
**Parameters:** Same as character_images, but size is wide/zoom
|
||||
|
||||
### Bulk Download
|
||||
|
||||
#### `granblue:download_all_images`
|
||||
Download all images for a type with parallel processing.
|
||||
|
||||
```bash
|
||||
# Download with parallel threads
|
||||
rake granblue:download_all_images[character,4]
|
||||
rake granblue:download_all_images[weapon,8]
|
||||
rake granblue:download_all_images[summon,4]
|
||||
rake granblue:download_all_images[job,2]
|
||||
|
||||
# Download specific size with threads
|
||||
rake granblue:download_all_images[character,4,grid]
|
||||
rake granblue:download_all_images[weapon,8,main]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `object`: character/weapon/summon/job
|
||||
- `threads`: Number of parallel threads (default: 4)
|
||||
- `size`: Specific size to download (optional)
|
||||
|
||||
## Data Import Tasks
|
||||
|
||||
### Import from CSV
|
||||
|
||||
#### `granblue:import_data`
|
||||
Import character, weapon, and summon data from CSV files.
|
||||
|
||||
```bash
|
||||
# Import all CSV files from db/seed/updates/
|
||||
rake granblue:import_data
|
||||
|
||||
# Test mode - validate without importing
|
||||
rake granblue:import_data TEST=true
|
||||
|
||||
# Verbose output
|
||||
rake granblue:import_data VERBOSE=true
|
||||
|
||||
# Both test and verbose
|
||||
rake granblue:import_data TEST=true VERBOSE=true
|
||||
```
|
||||
|
||||
**CSV Location:** `db/seed/updates/`
|
||||
**File naming:** `{type}_YYYYMMDD.csv`
|
||||
|
||||
## Wiki Data Tasks
|
||||
|
||||
### Fetch Wiki Data
|
||||
|
||||
#### `granblue:fetch_wiki_data`
|
||||
Fetch and store wiki data for game objects.
|
||||
|
||||
```bash
|
||||
# Fetch all characters (default)
|
||||
rake granblue:fetch_wiki_data
|
||||
|
||||
# Fetch specific type
|
||||
rake granblue:fetch_wiki_data type=Weapon
|
||||
rake granblue:fetch_wiki_data type=Summon
|
||||
rake granblue:fetch_wiki_data type=Character
|
||||
|
||||
# Fetch specific item
|
||||
rake granblue:fetch_wiki_data type=Character id=3040001000
|
||||
rake granblue:fetch_wiki_data type=Weapon id=1040001000
|
||||
|
||||
# Force re-fetch even if data exists
|
||||
rake granblue:fetch_wiki_data force=true
|
||||
rake granblue:fetch_wiki_data type=Summon force=true
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `type`: Character/Weapon/Summon (default: Character)
|
||||
- `id`: Specific Granblue ID (optional)
|
||||
- `force`: true/false - Re-fetch even if wiki_raw exists
|
||||
|
||||
## Export URL Tasks
|
||||
|
||||
### Job URLs
|
||||
|
||||
#### `granblue:export:job`
|
||||
Export list of job image URLs to text file.
|
||||
|
||||
```bash
|
||||
# Export icon URLs
|
||||
rake granblue:export:job[icon]
|
||||
|
||||
# Export portrait URLs
|
||||
rake granblue:export:job[portrait]
|
||||
```
|
||||
|
||||
**Output:** `export/job-{size}.txt`
|
||||
|
||||
### Export All URLs
|
||||
|
||||
#### `granblue:export:all`
|
||||
Export all asset URLs for batch downloading.
|
||||
|
||||
```bash
|
||||
# Export all URL lists
|
||||
rake granblue:export:all
|
||||
|
||||
# Creates files in export/
|
||||
# - character-*.txt
|
||||
# - weapon-*.txt
|
||||
# - summon-*.txt
|
||||
# - job-*.txt
|
||||
```
|
||||
|
||||
## Preview Tasks
|
||||
|
||||
### Generate Previews
|
||||
|
||||
#### `previews:generate_all`
|
||||
Generate preview images for all parties without previews.
|
||||
|
||||
```bash
|
||||
# Generate missing previews
|
||||
rake previews:generate_all
|
||||
|
||||
# Processes parties with pending/failed/nil preview_state
|
||||
# Uploads to S3: previews/{shortcode}.png
|
||||
```
|
||||
|
||||
#### `previews:regenerate_all`
|
||||
Regenerate preview images for all parties.
|
||||
|
||||
```bash
|
||||
# Regenerate all previews (overwrites existing)
|
||||
rake previews:regenerate_all
|
||||
```
|
||||
|
||||
## Database Tasks
|
||||
|
||||
### Database Management
|
||||
|
||||
#### `database:backup`
|
||||
Create database backup.
|
||||
|
||||
```bash
|
||||
# Create timestamped backup
|
||||
rake database:backup
|
||||
|
||||
# Output: backups/hensei_YYYYMMDD_HHMMSS.sql
|
||||
```
|
||||
|
||||
#### `database:restore`
|
||||
Restore database from backup.
|
||||
|
||||
```bash
|
||||
# Restore from latest backup
|
||||
rake database:restore
|
||||
|
||||
# Restore specific backup
|
||||
rake database:restore[backups/hensei_20240315_120000.sql]
|
||||
```
|
||||
|
||||
### Deployment Tasks
|
||||
|
||||
#### `deploy:post`
|
||||
Run post-deployment tasks.
|
||||
|
||||
```bash
|
||||
# Run all post-deployment tasks
|
||||
rake deploy:post
|
||||
|
||||
# Includes:
|
||||
# - Database migrations
|
||||
# - Data imports
|
||||
# - Asset compilation
|
||||
# - Cache clearing
|
||||
```
|
||||
|
||||
## Utility Tasks
|
||||
|
||||
### Download Images
|
||||
|
||||
#### `granblue:download_images`
|
||||
Legacy task for downloading images.
|
||||
|
||||
```bash
|
||||
# Download images (legacy)
|
||||
rake granblue:download_images
|
||||
```
|
||||
|
||||
**Note:** Prefer using the newer export tasks.
|
||||
|
||||
### Export Accessories
|
||||
|
||||
#### `granblue:export:accessories`
|
||||
Export accessory data and images.
|
||||
|
||||
```bash
|
||||
# Export all accessories
|
||||
rake granblue:export:accessories
|
||||
```
|
||||
|
||||
## Task Patterns and Options
|
||||
|
||||
### Common Parameters
|
||||
|
||||
Most tasks support these common parameters:
|
||||
|
||||
| Parameter | Values | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `test_mode` | true/false | Simulate without making changes |
|
||||
| `verbose` | true/false | Enable detailed logging |
|
||||
| `storage` | local/s3/both | Storage destination |
|
||||
| `force` | true/false | Force operation even if data exists |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Tasks can use environment variables:
|
||||
|
||||
```bash
|
||||
# Test mode
|
||||
TEST=true rake granblue:import_data
|
||||
|
||||
# Verbose output
|
||||
VERBOSE=true rake granblue:import_data
|
||||
|
||||
# Force re-processing
|
||||
FORCE=true rake granblue:fetch_wiki_data
|
||||
|
||||
# Custom environment
|
||||
RAILS_ENV=production rake granblue:export:character_images
|
||||
```
|
||||
|
||||
### Parameter Passing
|
||||
|
||||
Two ways to pass parameters:
|
||||
|
||||
1. **Bracket notation** (for defined parameters):
|
||||
```bash
|
||||
rake task_name[param1,param2,param3]
|
||||
```
|
||||
|
||||
2. **Environment variables** (for options):
|
||||
```bash
|
||||
TEST=true VERBOSE=true rake task_name
|
||||
```
|
||||
|
||||
### Skipping Parameters
|
||||
|
||||
Use commas to skip parameters:
|
||||
|
||||
```bash
|
||||
# Skip ID, set test_mode=true, verbose=true
|
||||
rake granblue:export:character_images[,true,true]
|
||||
|
||||
# Skip ID and test_mode, set verbose=true, storage=s3
|
||||
rake granblue:export:weapon_images[,,true,s3]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Test Before Running
|
||||
|
||||
Always test operations first:
|
||||
|
||||
```bash
|
||||
# Test mode
|
||||
rake granblue:import_data TEST=true
|
||||
|
||||
# Verify output
|
||||
rake granblue:export:character_images[,true,true]
|
||||
|
||||
# Then run for real
|
||||
rake granblue:export:character_images
|
||||
```
|
||||
|
||||
### 2. Use Parallel Processing
|
||||
|
||||
For bulk operations, use parallel threads:
|
||||
|
||||
```bash
|
||||
# Good - parallel
|
||||
rake granblue:download_all_images[character,8]
|
||||
|
||||
# Less efficient - sequential
|
||||
rake granblue:export:character_images
|
||||
```
|
||||
|
||||
### 3. Monitor Long-Running Tasks
|
||||
|
||||
Use verbose mode and logs:
|
||||
|
||||
```bash
|
||||
# Enable verbose
|
||||
rake granblue:export:character_images[,false,true]
|
||||
|
||||
# Watch logs in another terminal
|
||||
tail -f log/development.log
|
||||
```
|
||||
|
||||
### 4. Batch Large Operations
|
||||
|
||||
Break large operations into chunks:
|
||||
|
||||
```bash
|
||||
# Instead of all at once
|
||||
rake granblue:export:character_images
|
||||
|
||||
# Process specific batches
|
||||
rake granblue:export:character_images[3040001000]
|
||||
rake granblue:export:character_images[3040002000]
|
||||
```
|
||||
|
||||
### 5. Check Storage Space
|
||||
|
||||
Before bulk downloads:
|
||||
|
||||
```bash
|
||||
# Check local space
|
||||
df -h
|
||||
|
||||
# Check S3 usage
|
||||
aws s3 ls s3://bucket-name/ --recursive --summarize
|
||||
```
|
||||
|
||||
## Scheduling Tasks
|
||||
|
||||
### Cron Jobs
|
||||
|
||||
Example crontab entries:
|
||||
|
||||
```bash
|
||||
# Daily wiki data fetch at 2 AM
|
||||
0 2 * * * cd /app && rake granblue:fetch_wiki_data
|
||||
|
||||
# Weekly image download on Sunday at 3 AM
|
||||
0 3 * * 0 cd /app && rake granblue:download_all_images[character,4]
|
||||
|
||||
# Hourly preview generation
|
||||
0 * * * * cd /app && rake previews:generate_all
|
||||
```
|
||||
|
||||
### Whenever Gem
|
||||
|
||||
Using whenever for scheduling:
|
||||
|
||||
```ruby
|
||||
# config/schedule.rb
|
||||
every 1.day, at: '2:00 am' do
|
||||
rake "granblue:fetch_wiki_data"
|
||||
end
|
||||
|
||||
every :sunday, at: '3:00 am' do
|
||||
rake "granblue:download_all_images[character,4]"
|
||||
end
|
||||
|
||||
every 1.hour do
|
||||
rake "previews:generate_all"
|
||||
end
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Task Not Found
|
||||
|
||||
```bash
|
||||
# Check task exists
|
||||
rake -T | grep task_name
|
||||
|
||||
# Ensure in correct directory
|
||||
pwd # Should be Rails root
|
||||
|
||||
# Load Rails environment
|
||||
rake -T RAILS_ENV=development
|
||||
```
|
||||
|
||||
### Permission Errors
|
||||
|
||||
```bash
|
||||
# Check file permissions
|
||||
ls -la lib/tasks/
|
||||
|
||||
# Fix permissions
|
||||
chmod +x lib/tasks/*.rake
|
||||
|
||||
# Run with bundle
|
||||
bundle exec rake task_name
|
||||
```
|
||||
|
||||
### Memory Issues
|
||||
|
||||
```bash
|
||||
# Increase memory for large operations
|
||||
export RUBY_HEAP_MIN_SLOTS=500000
|
||||
export RUBY_GC_MALLOC_LIMIT=90000000
|
||||
|
||||
rake granblue:download_all_images[character,2]
|
||||
```
|
||||
|
||||
### Slow Performance
|
||||
|
||||
1. Use parallel processing
|
||||
2. Run during off-peak hours
|
||||
3. Process in smaller batches
|
||||
4. Check network bandwidth
|
||||
5. Monitor database connections
|
||||
|
||||
### Debugging
|
||||
|
||||
```bash
|
||||
# Enable debug output
|
||||
DEBUG=true rake task_name
|
||||
|
||||
# Rails console for testing
|
||||
rails console
|
||||
> load 'lib/tasks/task_name.rake'
|
||||
> Rake::Task['task_name'].invoke
|
||||
|
||||
# Trace task execution
|
||||
rake --trace task_name
|
||||
```
|
||||
|
||||
## Creating Custom Tasks
|
||||
|
||||
### Basic Task Structure
|
||||
|
||||
```ruby
|
||||
# lib/tasks/custom.rake
|
||||
namespace :custom do
|
||||
desc "Description of the task"
|
||||
task :task_name, [:param1, :param2] => :environment do |t, args|
|
||||
# Set defaults
|
||||
param1 = args[:param1] || 'default'
|
||||
param2 = args[:param2] == 'true'
|
||||
|
||||
# Task logic
|
||||
puts "Running with #{param1}, #{param2}"
|
||||
|
||||
# Use Rails models
|
||||
Model.find_each do |record|
|
||||
# Process record
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Task with Dependencies
|
||||
|
||||
```ruby
|
||||
namespace :custom do
|
||||
task :prepare do
|
||||
puts "Preparing..."
|
||||
end
|
||||
|
||||
task :cleanup do
|
||||
puts "Cleaning up..."
|
||||
end
|
||||
|
||||
desc "Main task with dependencies"
|
||||
task main: [:prepare, :environment] do
|
||||
puts "Running main task"
|
||||
|
||||
# Main logic here
|
||||
|
||||
Rake::Task['custom:cleanup'].invoke
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Task with Error Handling
|
||||
|
||||
```ruby
|
||||
namespace :custom do
|
||||
desc "Task with error handling"
|
||||
task safe_task: :environment do
|
||||
begin
|
||||
# Risky operation
|
||||
process_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Task failed: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
exit 1
|
||||
ensure
|
||||
# Cleanup
|
||||
cleanup_temp_files
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
250
docs/rspec-test-analysis.md
Normal file
250
docs/rspec-test-analysis.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# RSpec Test Suite Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The hensei-api project has a partial test suite with significant coverage gaps. While the existing tests demonstrate good practices and patterns, only about 35% of models and 33% of controllers have test coverage. The test suite contains 36 spec files with approximately 3,713 lines of test code.
|
||||
|
||||
## Test Coverage Overview
|
||||
|
||||
### Current State
|
||||
|
||||
- **Total Spec Files**: 36
|
||||
- **Total Test Lines**: ~3,713 lines
|
||||
- **SimpleCov**: Configured but basic setup only
|
||||
- **CI/CD**: No CI configuration detected (.github, .gitlab-ci, .circleci)
|
||||
|
||||
### Coverage by Component
|
||||
|
||||
#### Models (8/23 = 35% coverage)
|
||||
**Tested Models:**
|
||||
- `gacha_spec.rb`
|
||||
- `grid_characters_spec.rb`
|
||||
- `grid_summons_spec.rb`
|
||||
- `grid_weapon_spec.rb`
|
||||
- `party_spec.rb`
|
||||
- `user_spec.rb` (contains only pending example)
|
||||
- `weapon_key_spec.rb`
|
||||
- `weapon_spec.rb`
|
||||
|
||||
**Missing Tests (15 models):**
|
||||
- `app_update`
|
||||
- `application_record`
|
||||
- `awakening`
|
||||
- `character`
|
||||
- `data_version`
|
||||
- `favorite`
|
||||
- `grid_character` (note: grid_characters_spec exists)
|
||||
- `grid_summon` (note: grid_summons_spec exists)
|
||||
- `guidebook`
|
||||
- `job`
|
||||
- `job_accessory`
|
||||
- `job_skill`
|
||||
- `raid`
|
||||
- `raid_group`
|
||||
- `summon`
|
||||
- `weapon_awakening`
|
||||
|
||||
#### Controllers/Requests (8/24 = 33% coverage)
|
||||
**Tested Endpoints:**
|
||||
- `drag_drop_api_spec.rb`
|
||||
- `drag_drop_endpoints_spec.rb`
|
||||
- `grid_characters_controller_spec.rb`
|
||||
- `grid_summons_controller_spec.rb`
|
||||
- `grid_weapons_controller_spec.rb`
|
||||
- `import_controller_spec.rb`
|
||||
- `job_skills_spec.rb`
|
||||
- `parties_controller_spec.rb`
|
||||
|
||||
**Controller Concerns:**
|
||||
- `party_authorization_concern_spec.rb`
|
||||
- `party_querying_concern_spec.rb`
|
||||
|
||||
#### Services (5 tested)
|
||||
- `party_query_builder_spec.rb`
|
||||
- `processors/base_processor_spec.rb`
|
||||
- `processors/character_processor_spec.rb`
|
||||
- `processors/job_processor_spec.rb`
|
||||
- `processors/summon_processor_spec.rb`
|
||||
- `processors/weapon_processor_spec.rb`
|
||||
|
||||
## Test Quality Assessment
|
||||
|
||||
### Strengths
|
||||
|
||||
1. **Well-Structured Tests**
|
||||
- Clear describe/context/it blocks
|
||||
- Good use of RSpec conventions
|
||||
- Descriptive test names
|
||||
|
||||
2. **Comprehensive Validation Testing**
|
||||
```ruby
|
||||
# Example from party_spec.rb
|
||||
context 'for element' do
|
||||
it 'is valid when element is nil'
|
||||
it 'is valid when element is one of the allowed values'
|
||||
it 'is invalid when element is not one of the allowed values'
|
||||
it 'is invalid when element is not an integer'
|
||||
end
|
||||
```
|
||||
|
||||
3. **Good Factory Usage**
|
||||
- FactoryBot configured with appropriate defaults
|
||||
- Uses Faker for realistic test data
|
||||
- Sequences for unique values
|
||||
|
||||
4. **Authorization Testing**
|
||||
- Tests for both authenticated and anonymous users
|
||||
- Edit key validation for anonymous parties
|
||||
- Owner vs non-owner permission checks
|
||||
|
||||
5. **Request Specs Follow Best Practices**
|
||||
- Full request cycle testing
|
||||
- JSON response parsing
|
||||
- HTTP status code verification
|
||||
- Database change expectations
|
||||
|
||||
### Weaknesses
|
||||
|
||||
1. **Low Coverage**
|
||||
- 65% of models untested
|
||||
- 67% of controllers untested
|
||||
- Critical models like `Character`, `Summon`, `Job` lack tests
|
||||
|
||||
2. **Incomplete Test Files**
|
||||
- `user_spec.rb` contains only: `pending "add some examples"`
|
||||
- No actual tests for User model despite it being central to authentication
|
||||
|
||||
3. **Missing Integration Tests**
|
||||
- No end-to-end workflow tests
|
||||
- No tests for complex multi-model interactions
|
||||
- Missing tests for background jobs
|
||||
|
||||
4. **No Performance Tests**
|
||||
- No tests for query optimization
|
||||
- No load testing for endpoints
|
||||
- No N+1 query detection
|
||||
|
||||
5. **Limited Error Scenario Testing**
|
||||
- Few tests for error handling
|
||||
- Missing edge case coverage
|
||||
- Limited testing of failure scenarios
|
||||
|
||||
## Test Patterns and Conventions
|
||||
|
||||
### Model Tests Pattern
|
||||
```ruby
|
||||
RSpec.describe Model, type: :model do
|
||||
# Association tests
|
||||
it { is_expected.to belong_to(:related_model) }
|
||||
|
||||
# Validation tests
|
||||
describe 'validations' do
|
||||
it { should validate_presence_of(:field) }
|
||||
it { should validate_numericality_of(:number_field) }
|
||||
end
|
||||
|
||||
# Custom method tests
|
||||
describe '#custom_method' do
|
||||
# Test implementation
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Request Tests Pattern
|
||||
```ruby
|
||||
RSpec.describe 'API Endpoint', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:access_token) { create_doorkeeper_token(user) }
|
||||
let(:headers) { auth_headers(access_token) }
|
||||
|
||||
describe 'POST /endpoint' do
|
||||
it 'creates resource' do
|
||||
expect { post '/api/v1/endpoint', params: params, headers: headers }
|
||||
.to change(Model, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Critical Gaps
|
||||
|
||||
### High Priority Missing Tests
|
||||
|
||||
1. **Authentication & Authorization**
|
||||
- User model specs incomplete
|
||||
- No tests for OAuth/Doorkeeper integration
|
||||
- Missing role-based access control tests
|
||||
|
||||
2. **Core Domain Models**
|
||||
- Character model (central to parties)
|
||||
- Summon model (key grid component)
|
||||
- Weapon/Awakening models
|
||||
- Job and JobSkill models
|
||||
|
||||
3. **Data Import/Export**
|
||||
- Limited import controller testing
|
||||
- No export functionality tests
|
||||
- Missing validation for imported data
|
||||
|
||||
4. **Collection Features**
|
||||
- No tests for planned collection tracking
|
||||
- Missing artifact system tests
|
||||
- No crew feature tests
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Actions
|
||||
|
||||
1. **Complete User Model Tests**
|
||||
- Replace pending example with actual tests
|
||||
- Test authentication methods
|
||||
- Test associations and validations
|
||||
|
||||
2. **Add Core Model Tests**
|
||||
- Priority: Character, Summon, Job models
|
||||
- Focus on validations and associations
|
||||
- Test business logic methods
|
||||
|
||||
3. **Implement CI/CD**
|
||||
- Set up GitHub Actions or GitLab CI
|
||||
- Run tests on every PR
|
||||
- Add coverage reporting
|
||||
|
||||
### Short-term Improvements
|
||||
|
||||
1. **Increase Coverage Target**
|
||||
- Aim for 80% model coverage
|
||||
- Aim for 70% controller coverage
|
||||
- Use SimpleCov to track progress
|
||||
|
||||
2. **Add Integration Tests**
|
||||
- Test complete user workflows
|
||||
- Test party creation with all components
|
||||
- Test import/export flows
|
||||
|
||||
3. **Implement Test Helpers**
|
||||
- Create shared examples for common patterns
|
||||
- Add custom matchers for domain logic
|
||||
- Build test data builders for complex scenarios
|
||||
|
||||
### Long-term Goals
|
||||
|
||||
1. **Comprehensive Test Suite**
|
||||
- Achieve 90%+ code coverage
|
||||
- Add performance test suite
|
||||
- Implement mutation testing
|
||||
|
||||
2. **Test Documentation**
|
||||
- Document testing conventions
|
||||
- Create testing guidelines
|
||||
- Maintain test writing standards
|
||||
|
||||
3. **Automated Quality Checks**
|
||||
- Pre-commit hooks for tests
|
||||
- Automated coverage reporting
|
||||
- Test quality metrics tracking
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current test suite provides a solid foundation with good patterns and practices, but significant gaps exist in coverage. The testing infrastructure (FactoryBot, RSpec configuration) is well-set up, making it straightforward to add missing tests. Priority should be given to testing core domain models and implementing CI/CD to ensure test execution on every code change.
|
||||
560
docs/transformers.md
Normal file
560
docs/transformers.md
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
# Data Transformers Documentation
|
||||
|
||||
The transformer system converts game data between different formats and structures. It handles element mapping, data normalization, and format conversions for characters, weapons, and summons.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Base Transformer
|
||||
|
||||
All transformers inherit from `BaseTransformer` which provides:
|
||||
- Data validation
|
||||
- Element mapping (game ↔ internal)
|
||||
- Error handling with detailed context
|
||||
- Debug logging
|
||||
- Common transformation utilities
|
||||
|
||||
### Element Mapping
|
||||
|
||||
The system uses different element IDs internally vs the game:
|
||||
|
||||
```ruby
|
||||
ELEMENT_MAPPING = {
|
||||
0 => nil, # Null/None
|
||||
1 => 4, # Wind → Earth
|
||||
2 => 2, # Fire → Fire
|
||||
3 => 3, # Water → Water
|
||||
4 => 1, # Earth → Wind
|
||||
5 => 6, # Dark → Light
|
||||
6 => 5 # Light → Dark
|
||||
}
|
||||
```
|
||||
|
||||
### Available Transformers
|
||||
|
||||
#### CharacterTransformer
|
||||
Transforms character data for different contexts.
|
||||
|
||||
**Transformations:**
|
||||
- Game format → Database format
|
||||
- Database format → API response
|
||||
- Wiki data → Database format
|
||||
- Legacy format → Current format
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
# Transform game data to database format
|
||||
game_data = {
|
||||
id: "3040001000",
|
||||
name: "Katalina",
|
||||
element: 3, # Water in game format
|
||||
hp: 1680,
|
||||
atk: 7200
|
||||
}
|
||||
|
||||
transformer = Granblue::Transformers::CharacterTransformer.new(game_data)
|
||||
db_data = transformer.transform
|
||||
# => { granblue_id: "3040001000", name_en: "Katalina", element: 3, ... }
|
||||
|
||||
# Transform for API response
|
||||
transformer = Granblue::Transformers::CharacterTransformer.new(
|
||||
character,
|
||||
format: :api
|
||||
)
|
||||
api_data = transformer.transform
|
||||
```
|
||||
|
||||
#### WeaponTransformer
|
||||
Transforms weapon data between formats.
|
||||
|
||||
**Transformations:**
|
||||
- Skill format conversions
|
||||
- Awakening data mapping
|
||||
- Element transformations
|
||||
- Legacy skill migrations
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
weapon_data = {
|
||||
id: "1040001000",
|
||||
name: "Murgleis",
|
||||
element: 3,
|
||||
skills: [{ name: "Hoarfrost's Might", level: 10 }]
|
||||
}
|
||||
|
||||
transformer = Granblue::Transformers::WeaponTransformer.new(weapon_data)
|
||||
transformed = transformer.transform
|
||||
```
|
||||
|
||||
#### SummonTransformer
|
||||
Transforms summon data between formats.
|
||||
|
||||
**Transformations:**
|
||||
- Call effect formatting
|
||||
- Aura data structuring
|
||||
- Sub-aura conversions
|
||||
- Cooldown normalization
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
summon_data = {
|
||||
id: "2040001000",
|
||||
name: "Bahamut",
|
||||
element: 0,
|
||||
call_effect: "120% Dark damage",
|
||||
initial_cd: 9,
|
||||
recast: 10
|
||||
}
|
||||
|
||||
transformer = Granblue::Transformers::SummonTransformer.new(summon_data)
|
||||
transformed = transformer.transform
|
||||
```
|
||||
|
||||
#### BaseDeckTransformer
|
||||
Transforms party/deck configurations.
|
||||
|
||||
**Transformations:**
|
||||
- Party format → Deck format
|
||||
- Grid positions mapping
|
||||
- Equipment slot conversions
|
||||
- Skill selection formatting
|
||||
|
||||
**Usage:**
|
||||
```ruby
|
||||
party_data = {
|
||||
characters: [char1, char2, char3],
|
||||
weapons: [weapon1, weapon2, ...],
|
||||
summons: [summon1, summon2, ...]
|
||||
}
|
||||
|
||||
transformer = Granblue::Transformers::BaseDeckTransformer.new(party_data)
|
||||
deck = transformer.transform
|
||||
```
|
||||
|
||||
## Transformation Patterns
|
||||
|
||||
### Input Validation
|
||||
|
||||
```ruby
|
||||
class CustomTransformer < BaseTransformer
|
||||
def transform
|
||||
validate_data
|
||||
|
||||
# Transformation logic
|
||||
{
|
||||
id: @data[:id],
|
||||
name: transform_name(@data[:name]),
|
||||
element: transform_element(@data[:element])
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_data
|
||||
raise TransformerError.new("Missing ID") if @data[:id].blank?
|
||||
raise TransformerError.new("Invalid element") unless valid_element?
|
||||
end
|
||||
|
||||
def valid_element?
|
||||
(0..6).include?(@data[:element].to_i)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Element Transformation
|
||||
|
||||
```ruby
|
||||
# Game to internal
|
||||
def transform_element_to_internal(game_element)
|
||||
ELEMENT_MAPPING[game_element] || 0
|
||||
end
|
||||
|
||||
# Internal to game
|
||||
def transform_element_to_game(internal_element)
|
||||
ELEMENT_MAPPING.invert[internal_element] || 0
|
||||
end
|
||||
|
||||
# Element name
|
||||
def element_name(element_id)
|
||||
%w[Null Wind Fire Water Earth Dark Light][element_id]
|
||||
end
|
||||
```
|
||||
|
||||
### Safe Value Extraction
|
||||
|
||||
```ruby
|
||||
def safe_integer(value, default = 0)
|
||||
Integer(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
default
|
||||
end
|
||||
|
||||
def safe_string(value, default = "")
|
||||
value.to_s.presence || default
|
||||
end
|
||||
|
||||
def safe_boolean(value, default = false)
|
||||
return default if value.nil?
|
||||
ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
```
|
||||
|
||||
### Nested Data Transformation
|
||||
|
||||
```ruby
|
||||
def transform_skills(skills)
|
||||
return [] if skills.blank?
|
||||
|
||||
skills.map do |skill|
|
||||
{
|
||||
name: safe_string(skill[:name]),
|
||||
description: safe_string(skill[:description]),
|
||||
cooldown: safe_integer(skill[:cd]),
|
||||
effects: transform_skill_effects(skill[:effects])
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def transform_skill_effects(effects)
|
||||
return [] if effects.blank?
|
||||
|
||||
effects.map do |effect|
|
||||
{
|
||||
type: effect[:type].to_s.underscore,
|
||||
value: safe_integer(effect[:value]),
|
||||
target: effect[:target] || "self"
|
||||
}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### TransformerError
|
||||
|
||||
Custom error class with context:
|
||||
|
||||
```ruby
|
||||
class TransformerError < StandardError
|
||||
attr_reader :details
|
||||
|
||||
def initialize(message, details = nil)
|
||||
@details = details
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
raise TransformerError.new(
|
||||
"Invalid skill format",
|
||||
{ skill: skill_data, index: index }
|
||||
)
|
||||
```
|
||||
|
||||
### Error Recovery
|
||||
|
||||
```ruby
|
||||
def transform_with_fallback
|
||||
begin
|
||||
primary_transform
|
||||
rescue TransformerError => e
|
||||
Rails.logger.warn "Transform failed: #{e.message}"
|
||||
fallback_transform
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
```ruby
|
||||
def validate_and_transform
|
||||
errors = []
|
||||
|
||||
errors << "Missing name" if @data[:name].blank?
|
||||
errors << "Invalid HP" if @data[:hp].to_i <= 0
|
||||
errors << "Invalid element" unless valid_element?
|
||||
|
||||
if errors.any?
|
||||
raise TransformerError.new(
|
||||
"Validation failed",
|
||||
{ errors: errors, data: @data }
|
||||
)
|
||||
end
|
||||
|
||||
perform_transform
|
||||
end
|
||||
```
|
||||
|
||||
## Format Specifications
|
||||
|
||||
### API Format
|
||||
|
||||
```ruby
|
||||
class ApiTransformer < BaseTransformer
|
||||
def transform
|
||||
{
|
||||
id: @data.granblue_id,
|
||||
name: {
|
||||
en: @data.name_en,
|
||||
jp: @data.name_jp
|
||||
},
|
||||
element: element_name(@data.element),
|
||||
rarity: rarity_string(@data.rarity),
|
||||
stats: {
|
||||
hp: @data.hp,
|
||||
atk: @data.atk
|
||||
},
|
||||
skills: transform_skills(@data.skills)
|
||||
}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Database Format
|
||||
|
||||
```ruby
|
||||
class DatabaseTransformer < BaseTransformer
|
||||
def transform
|
||||
{
|
||||
granblue_id: @data[:id].to_s,
|
||||
name_en: @data[:name][:en],
|
||||
name_jp: @data[:name][:jp],
|
||||
element: parse_element(@data[:element]),
|
||||
rarity: parse_rarity(@data[:rarity]),
|
||||
hp: @data[:stats][:hp].to_i,
|
||||
atk: @data[:stats][:atk].to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Legacy Format Migration
|
||||
|
||||
```ruby
|
||||
class LegacyTransformer < BaseTransformer
|
||||
def transform
|
||||
# Map old field names to new
|
||||
{
|
||||
granblue_id: @data[:char_id] || @data[:id],
|
||||
name_en: @data[:name_english] || @data[:name],
|
||||
element: map_legacy_element(@data[:elem]),
|
||||
# Handle removed fields
|
||||
deprecated_field: nil
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def map_legacy_element(elem)
|
||||
# Old system used different IDs
|
||||
legacy_mapping = {
|
||||
"wind" => 1,
|
||||
"fire" => 2,
|
||||
"water" => 3,
|
||||
"earth" => 4
|
||||
}
|
||||
legacy_mapping[elem.to_s.downcase] || 0
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Validate Input
|
||||
```ruby
|
||||
def transform
|
||||
validate_required_fields
|
||||
validate_data_types
|
||||
validate_ranges
|
||||
|
||||
perform_transform
|
||||
end
|
||||
```
|
||||
|
||||
### 2. Use Safe Extraction Methods
|
||||
```ruby
|
||||
# Good
|
||||
name = safe_string(@data[:name], "Unknown")
|
||||
|
||||
# Bad
|
||||
name = @data[:name] # Could be nil
|
||||
```
|
||||
|
||||
### 3. Provide Clear Error Messages
|
||||
```ruby
|
||||
raise TransformerError.new(
|
||||
"Element value '#{element}' is not valid. Expected 0-6.",
|
||||
{ element: element, valid_range: (0..6) }
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Log Transformations
|
||||
```ruby
|
||||
def transform
|
||||
Rails.logger.info "[TRANSFORM] Starting #{self.class.name}"
|
||||
result = perform_transform
|
||||
Rails.logger.info "[TRANSFORM] Completed with #{result.keys.count} fields"
|
||||
result
|
||||
rescue => e
|
||||
Rails.logger.error "[TRANSFORM] Failed: #{e.message}"
|
||||
raise
|
||||
end
|
||||
```
|
||||
|
||||
### 5. Handle Missing Data Gracefully
|
||||
```ruby
|
||||
def transform_optional_field(value)
|
||||
return nil if value.blank?
|
||||
|
||||
# Transform only if present
|
||||
value.to_s.upcase
|
||||
end
|
||||
```
|
||||
|
||||
## Custom Transformer Implementation
|
||||
|
||||
```ruby
|
||||
module Granblue
|
||||
module Transformers
|
||||
class CustomTransformer < BaseTransformer
|
||||
# Define transformation options
|
||||
OPTIONS = {
|
||||
format: :database,
|
||||
include_metadata: false,
|
||||
validate_strict: true
|
||||
}.freeze
|
||||
|
||||
def initialize(data, options = {})
|
||||
super(data, OPTIONS.merge(options))
|
||||
end
|
||||
|
||||
def transform
|
||||
validate_data if @options[:validate_strict]
|
||||
|
||||
base_transform.tap do |result|
|
||||
result[:metadata] = metadata if @options[:include_metadata]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_transform
|
||||
{
|
||||
id: @data[:id],
|
||||
type: determine_type,
|
||||
attributes: transform_attributes,
|
||||
relationships: transform_relationships
|
||||
}
|
||||
end
|
||||
|
||||
def transform_attributes
|
||||
{
|
||||
name: safe_string(@data[:name]),
|
||||
description: safe_string(@data[:desc]),
|
||||
stats: transform_stats
|
||||
}
|
||||
end
|
||||
|
||||
def transform_stats
|
||||
return {} unless @data[:stats]
|
||||
|
||||
@data[:stats].transform_values { |v| safe_integer(v) }
|
||||
end
|
||||
|
||||
def transform_relationships
|
||||
{
|
||||
parent_id: @data[:parent],
|
||||
child_ids: Array(@data[:children])
|
||||
}
|
||||
end
|
||||
|
||||
def metadata
|
||||
{
|
||||
transformed_at: Time.current,
|
||||
transformer: self.class.name,
|
||||
version: "1.0"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Testing Transformers
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```ruby
|
||||
RSpec.describe Granblue::Transformers::CharacterTransformer do
|
||||
let(:input_data) do
|
||||
{
|
||||
id: "3040001000",
|
||||
name: "Test Character",
|
||||
element: 3,
|
||||
hp: 1000,
|
||||
atk: 500
|
||||
}
|
||||
end
|
||||
|
||||
subject { described_class.new(input_data) }
|
||||
|
||||
describe "#transform" do
|
||||
it "transforms game data to database format" do
|
||||
result = subject.transform
|
||||
|
||||
expect(result[:granblue_id]).to eq("3040001000")
|
||||
expect(result[:name_en]).to eq("Test Character")
|
||||
expect(result[:element]).to eq(3)
|
||||
end
|
||||
|
||||
it "handles missing optional fields" do
|
||||
input_data.delete(:hp)
|
||||
|
||||
expect { subject.transform }.not_to raise_error
|
||||
end
|
||||
|
||||
it "raises error for invalid element" do
|
||||
input_data[:element] = 99
|
||||
|
||||
expect { subject.transform }.to raise_error(TransformerError)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```ruby
|
||||
# Test full pipeline
|
||||
character_data = fetch_from_api
|
||||
transformer = CharacterTransformer.new(character_data)
|
||||
transformed = transformer.transform
|
||||
character = Character.create!(transformed)
|
||||
|
||||
expect(character.persisted?).to be true
|
||||
expect(character.element).to eq(transformed[:element])
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Transformation Returns Nil
|
||||
1. Check input data is not nil
|
||||
2. Verify required fields are present
|
||||
3. Enable debug logging
|
||||
4. Check for silent rescue blocks
|
||||
|
||||
### Wrong Element Mapping
|
||||
1. Verify using correct mapping direction
|
||||
2. Check for element ID vs name confusion
|
||||
3. Ensure consistent element system
|
||||
|
||||
### Data Loss During Transform
|
||||
1. Check all fields are mapped
|
||||
2. Verify no fields silently dropped
|
||||
3. Add logging for each field
|
||||
4. Compare input and output keys
|
||||
|
||||
### Performance Issues
|
||||
1. Cache repeated transformations
|
||||
2. Use batch transformations
|
||||
3. Avoid N+1 queries in transformers
|
||||
4. Profile transformation methods
|
||||
|
|
@ -29,6 +29,25 @@ module Granblue
|
|||
# @return [Array<String>] Available image size variants
|
||||
SIZES = %w[main grid square].freeze
|
||||
|
||||
# Class-level AWS service instance (shared across all downloaders)
|
||||
class << self
|
||||
def aws_service
|
||||
@aws_service_mutex ||= Mutex.new
|
||||
@aws_service_mutex.synchronize do
|
||||
@aws_service ||= AwsService.new
|
||||
end
|
||||
end
|
||||
|
||||
# Reset the shared AWS service (useful for testing)
|
||||
# @return [void]
|
||||
def reset_aws_service
|
||||
@aws_service_mutex ||= Mutex.new
|
||||
@aws_service_mutex.synchronize do
|
||||
@aws_service = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Initialize a new downloader instance
|
||||
# @param id [String] ID of the object to download images for
|
||||
# @param test_mode [Boolean] When true, only logs actions without downloading
|
||||
|
|
@ -42,7 +61,7 @@ module Granblue
|
|||
@verbose = verbose
|
||||
@storage = storage
|
||||
@logger = logger || Logger.new($stdout) # fallback logger
|
||||
@aws_service = AwsService.new
|
||||
@aws_service = self.class.aws_service if store_in_s3?
|
||||
ensure_directories_exist unless @test_mode
|
||||
end
|
||||
|
||||
|
|
@ -158,7 +177,8 @@ module Granblue
|
|||
def ensure_directories_exist
|
||||
return unless store_locally?
|
||||
|
||||
SIZES.each do |size|
|
||||
sizes = self.class::SIZES rescue SIZES
|
||||
sizes.each do |size|
|
||||
FileUtils.mkdir_p(download_path(size))
|
||||
end
|
||||
end
|
||||
|
|
@ -169,6 +189,12 @@ module Granblue
|
|||
%i[local both].include?(@storage)
|
||||
end
|
||||
|
||||
# Check if S3 storage is being used
|
||||
# @return [Boolean] true if storing to S3
|
||||
def store_in_s3?
|
||||
%i[s3 both].include?(@storage)
|
||||
end
|
||||
|
||||
# Get local download path for a size
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Local directory path
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ module Granblue
|
|||
# @note Character images come in multiple variants (_01, _02, etc.) based on uncap status
|
||||
# @note Supports FLB (5★) and ULB (6★) art variants when available
|
||||
class CharacterDownloader < BaseDownloader
|
||||
# Override SIZES to include 'detail' for detail images
|
||||
SIZES = %w[main grid square detail].freeze
|
||||
# Downloads images for all variants of a character based on their uncap status.
|
||||
# Overrides {BaseDownloader#download} to handle character-specific variants.
|
||||
#
|
||||
|
|
@ -66,7 +68,7 @@ module Granblue
|
|||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_variant_url(variant_id, size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
process_download(url, size, path, last: index == sizes.size - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -94,7 +96,7 @@ module Granblue
|
|||
# Gets base URL for character assets
|
||||
# @return [String] Base URL for character images
|
||||
def base_url
|
||||
'http://gbf.game-a.mbga.jp/assets/img/sp/assets/npc'
|
||||
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/npc'
|
||||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
|
|
|
|||
171
lib/granblue/downloaders/job_downloader.rb
Normal file
171
lib/granblue/downloaders/job_downloader.rb
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Granblue
|
||||
module Downloaders
|
||||
# Downloads job image assets from the game server in different sizes and variants.
|
||||
# Handles job-specific images including wide and zoom formats with gender variants.
|
||||
#
|
||||
# @example Download images for a specific job
|
||||
# downloader = JobDownloader.new("100401", storage: :both)
|
||||
# downloader.download
|
||||
#
|
||||
# @note Job images come in wide and zoom formats only
|
||||
# @note Zoom images have male (0) and female (1) variants
|
||||
class JobDownloader < BaseDownloader
|
||||
# Override SIZES to include only 'wide' and 'zoom' for job images
|
||||
SIZES = %w[wide zoom].freeze
|
||||
|
||||
# Downloads images for all variants of a job.
|
||||
# Overrides {BaseDownloader#download} to handle job-specific variants.
|
||||
#
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
# @note Skips download if job is not found in database
|
||||
# @note Downloads gender variants for zoom images
|
||||
def download(selected_size = nil)
|
||||
job = Job.find_by(granblue_id: @id)
|
||||
return unless job
|
||||
|
||||
download_variants(job, selected_size)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Downloads all variants of a job's images
|
||||
#
|
||||
# @param job [Job] Job model instance to download images for
|
||||
# @param selected_size [String] The size to download. If nil, downloads all sizes.
|
||||
# @return [void]
|
||||
def download_variants(job, selected_size = nil)
|
||||
log_info "-> #{@id}" if @verbose
|
||||
return if @test_mode
|
||||
|
||||
sizes = selected_size ? [selected_size] : SIZES
|
||||
|
||||
sizes.each_with_index do |size, index|
|
||||
case size
|
||||
when 'zoom'
|
||||
# Download both male and female variants for zoom images
|
||||
download_zoom_variants(index == sizes.size - 1)
|
||||
when 'wide'
|
||||
# Download both male and female variants for wide images
|
||||
download_wide_variants(index == sizes.size - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Downloads zoom variants for both genders
|
||||
#
|
||||
# @param is_last [Boolean] Whether zoom is the last size being processed
|
||||
# @return [void]
|
||||
def download_zoom_variants(is_last)
|
||||
path = download_path('zoom')
|
||||
|
||||
# Download male variant (_a suffix, 0 in URL)
|
||||
url_male = build_zoom_url(0)
|
||||
filename_male = "#{@id}_a.png"
|
||||
download_variant(url_male, 'zoom', path, filename_male, 'zoom-male', last: false)
|
||||
|
||||
# Download female variant (_b suffix, 1 in URL)
|
||||
url_female = build_zoom_url(1)
|
||||
filename_female = "#{@id}_b.png"
|
||||
download_variant(url_female, 'zoom', path, filename_female, 'zoom-female', last: is_last)
|
||||
end
|
||||
|
||||
# Downloads a specific variant
|
||||
#
|
||||
# @param url [String] URL to download from
|
||||
# @param size [String] Size category for S3 key
|
||||
# @param path [String] Local path for download
|
||||
# @param filename [String] Filename to save as
|
||||
# @param label [String] Label for logging
|
||||
# @param last [Boolean] Whether this is the last item being processed
|
||||
# @return [void]
|
||||
def download_variant(url, size, path, filename, label, last: false)
|
||||
s3_key = build_s3_key(size, filename)
|
||||
download_uri = "#{path}/#{filename}"
|
||||
|
||||
should_process = should_download?(download_uri, s3_key)
|
||||
return unless should_process
|
||||
|
||||
prefix = last ? "\t└" : "\t├"
|
||||
log_info "#{prefix} #{label}: #{url}..."
|
||||
|
||||
case @storage
|
||||
when :local
|
||||
download_to_local(url, download_uri)
|
||||
when :s3
|
||||
stream_to_s3(url, s3_key)
|
||||
when :both
|
||||
download_to_both(url, download_uri, s3_key)
|
||||
end
|
||||
rescue OpenURI::HTTPError
|
||||
log_info "\t404 returned\t#{url}"
|
||||
end
|
||||
|
||||
# Builds URL for wide images
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Complete URL for downloading the image
|
||||
def build_job_url(size)
|
||||
case size
|
||||
when 'wide'
|
||||
# Wide images always download both male and female variants
|
||||
nil # Will be handled by download_wide_variants
|
||||
when 'zoom'
|
||||
nil # Handled by build_zoom_url
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Downloads wide variants for both genders
|
||||
#
|
||||
# @param is_last [Boolean] Whether wide is the last size being processed
|
||||
# @return [void]
|
||||
def download_wide_variants(is_last)
|
||||
path = download_path('wide')
|
||||
|
||||
# Download male variant (_a suffix, _01 in URL)
|
||||
url_male = "https://prd-game-a3-granbluefantasy.akamaized.net/assets_en/img/sp/assets/leader/m/#{@id}_01.jpg"
|
||||
filename_male = "#{@id}_a.jpg"
|
||||
download_variant(url_male, 'wide', path, filename_male, 'wide-male', last: false)
|
||||
|
||||
# Download female variant (_b suffix, _01 in URL - same as male for wide)
|
||||
url_female = "https://prd-game-a3-granbluefantasy.akamaized.net/assets_en/img/sp/assets/leader/m/#{@id}_01.jpg"
|
||||
filename_female = "#{@id}_b.jpg"
|
||||
download_variant(url_female, 'wide', path, filename_female, 'wide-female', last: is_last)
|
||||
end
|
||||
|
||||
# Builds URL for zoom images with gender variant
|
||||
#
|
||||
# @param gender [Integer] Gender variant (0 for male, 1 for female)
|
||||
# @return [String] Complete URL for downloading the zoom image
|
||||
def build_zoom_url(gender)
|
||||
"https://media.skycompass.io/assets/customizes/jobs/1138x1138/#{@id}_#{gender}.png"
|
||||
end
|
||||
|
||||
|
||||
# Gets object type for file paths and storage keys
|
||||
# @return [String] Returns "job"
|
||||
def object_type
|
||||
'job'
|
||||
end
|
||||
|
||||
# Gets base URL for job assets
|
||||
# @return [String] Base URL for job images
|
||||
def base_url
|
||||
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/leader'
|
||||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Directory name in game asset URL structure
|
||||
# @note Jobs only have wide and zoom formats, handled with custom URLs
|
||||
def directory_for_size(size)
|
||||
nil # Jobs don't use the standard directory structure
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -12,6 +12,8 @@ module Granblue
|
|||
# @note Summon images come in multiple variants based on uncap status
|
||||
# @note Supports ULB (5★) and transcendence variants when available
|
||||
class SummonDownloader < BaseDownloader
|
||||
# Override SIZES to include 'wide' for m directory images and 'detail' for detail images
|
||||
SIZES = %w[main grid wide square detail].freeze
|
||||
# Downloads images for all variants of a summon based on their uncap status.
|
||||
# Overrides {BaseDownloader#download} to handle summon-specific variants.
|
||||
#
|
||||
|
|
@ -68,7 +70,7 @@ module Granblue
|
|||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_variant_url(variant_id, size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
process_download(url, size, path, last: index == sizes.size - 1)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -95,18 +97,19 @@ module Granblue
|
|||
# Gets base URL for summon assets
|
||||
# @return [String] Base URL for summon images
|
||||
def base_url
|
||||
'http://gbf.game-a.mbga.jp/assets/img/sp/assets/summon'
|
||||
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon'
|
||||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Directory name in game asset URL structure
|
||||
# @note Maps "main" -> "party_main", "grid" -> "party_sub", "square" -> "s"
|
||||
# @note Maps "main" -> "ls", "grid" -> "party_sub", "wide" -> "m", "square" -> "s"
|
||||
def directory_for_size(size)
|
||||
case size.to_s
|
||||
when 'main' then 'ls'
|
||||
when 'grid' then 'm'
|
||||
when 'grid' then 'party_sub'
|
||||
when 'wide' then 'm'
|
||||
when 'square' then 's'
|
||||
when 'detail' then 'detail'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ module Granblue
|
|||
# @note Supports transcendence variants and element-specific variants
|
||||
# @see ElementalWeaponDownloader for handling multi-element weapons
|
||||
class WeaponDownloader < BaseDownloader
|
||||
# Override SIZES to include 'base' for b directory images
|
||||
SIZES = %w[main grid square base].freeze
|
||||
# Downloads images for all variants of a weapon based on their uncap status.
|
||||
# Overrides {BaseDownloader#download} to handle weapon-specific variants.
|
||||
#
|
||||
|
|
@ -66,18 +68,18 @@ module Granblue
|
|||
sizes.each_with_index do |size, index|
|
||||
path = download_path(size)
|
||||
url = build_variant_url(variant_id, size)
|
||||
process_download(url, size, path, last: index == SIZES.size - 1)
|
||||
process_download(url, size, path, last: index == sizes.size - 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Builds URL for a specific variant and size
|
||||
#
|
||||
# @param variant_id [String] Weapon variant ID
|
||||
# @param size [String] Image size variant ("main", "grid", "square", or "raw")
|
||||
# @param size [String] Image size variant ("main", "grid", "square", or "base")
|
||||
# @return [String] Complete URL for downloading the image
|
||||
def build_variant_url(variant_id, size)
|
||||
directory = directory_for_size(size)
|
||||
if size == 'raw'
|
||||
if size == 'base'
|
||||
"#{@base_url}/#{directory}/#{variant_id}.png"
|
||||
else
|
||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||
|
|
@ -93,20 +95,20 @@ module Granblue
|
|||
# Gets base URL for weapon assets
|
||||
# @return [String] Base URL for weapon images
|
||||
def base_url
|
||||
'http://gbf.game-a.mbga.jp/assets/img/sp/assets/weapon'
|
||||
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/weapon'
|
||||
end
|
||||
|
||||
# Gets directory name for a size variant
|
||||
#
|
||||
# @param size [String] Image size variant
|
||||
# @return [String] Directory name in game asset URL structure
|
||||
# @note Maps "main" -> "ls", "grid" -> "m", "square" -> "s"
|
||||
# @note Maps "main" -> "ls", "grid" -> "m", "square" -> "s", "base" -> "b"
|
||||
def directory_for_size(size)
|
||||
case size.to_s
|
||||
when 'main' then 'ls'
|
||||
when 'grid' then 'm'
|
||||
when 'square' then 's'
|
||||
when 'raw' then 'b'
|
||||
when 'base' then 'b'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ namespace :granblue do
|
|||
namespace :export do
|
||||
def build_chara_url(id, size)
|
||||
# Set up URL
|
||||
base_url = 'http://gbf.game-a.mbga.jp/assets/img/sp/assets/npc'
|
||||
base_url = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/npc'
|
||||
extension = '.jpg'
|
||||
|
||||
directory = 'f' if size.to_s == 'main'
|
||||
|
|
|
|||
|
|
@ -69,5 +69,67 @@ namespace :granblue do
|
|||
task :job, [:size] => :environment do |_t, args|
|
||||
write_urls(args[:size])
|
||||
end
|
||||
|
||||
desc 'Download job images using the JobDownloader'
|
||||
task :job_images, [:id, :test_mode, :verbose, :storage, :size] => :environment do |_t, args|
|
||||
require 'granblue/downloaders/job_downloader'
|
||||
|
||||
id = args[:id]
|
||||
test_mode = args[:test_mode] == 'true'
|
||||
verbose = args[:verbose] != 'false' # Default to true
|
||||
storage = (args[:storage] || 'both').to_sym
|
||||
size = args[:size]
|
||||
|
||||
logger = Logger.new($stdout)
|
||||
|
||||
if id
|
||||
# Download a specific job
|
||||
job = Job.find_by(granblue_id: id)
|
||||
if job
|
||||
logger.info "Downloading images for job: #{job.name_en} (#{job.granblue_id})"
|
||||
logger.info "Test mode: #{test_mode}" if test_mode
|
||||
logger.info "Storage: #{storage}"
|
||||
logger.info "Size: #{size}" if size
|
||||
|
||||
downloader = Granblue::Downloaders::JobDownloader.new(
|
||||
job.granblue_id,
|
||||
test_mode: test_mode,
|
||||
verbose: verbose,
|
||||
storage: storage,
|
||||
logger: logger
|
||||
)
|
||||
downloader.download(size)
|
||||
else
|
||||
logger.error "Job not found with ID: #{id}"
|
||||
exit 1
|
||||
end
|
||||
else
|
||||
# Download all jobs
|
||||
jobs = Job.all.order(:granblue_id)
|
||||
total = jobs.count
|
||||
logger.info "Found #{total} jobs to process"
|
||||
logger.info "Test mode: #{test_mode}" if test_mode
|
||||
logger.info "Storage: #{storage}"
|
||||
logger.info "Size: #{size}" if size
|
||||
|
||||
jobs.each_with_index do |job, index|
|
||||
logger.info "[#{index + 1}/#{total}] Processing: #{job.name_en} (#{job.granblue_id})"
|
||||
|
||||
downloader = Granblue::Downloaders::JobDownloader.new(
|
||||
job.granblue_id,
|
||||
test_mode: test_mode,
|
||||
verbose: verbose,
|
||||
storage: storage,
|
||||
logger: logger
|
||||
)
|
||||
downloader.download(size)
|
||||
|
||||
# Add a small delay to avoid hammering the server
|
||||
sleep(0.5) unless test_mode
|
||||
end
|
||||
end
|
||||
|
||||
logger.info "Job image download completed!"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ namespace :granblue do
|
|||
namespace :export do
|
||||
def build_summon_url(id, size)
|
||||
# Set up URL
|
||||
base_url = 'http://gbf.game-a.mbga.jp/assets/img/sp/assets/summon'
|
||||
base_url = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon'
|
||||
extension = '.jpg'
|
||||
|
||||
directory = 'party_main' if size.to_s == 'main'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ namespace :granblue do
|
|||
namespace :export do
|
||||
def build_weapon_url(id, size)
|
||||
# Set up URL
|
||||
base_url = 'http://gbf.game-a.mbga.jp/assets/img/sp/assets/weapon'
|
||||
base_url = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/weapon'
|
||||
extension = '.jpg'
|
||||
|
||||
directory = 'ls' if size.to_s == 'main'
|
||||
|
|
|
|||
21
spec/factories/awakenings.rb
Normal file
21
spec/factories/awakenings.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FactoryBot.define do
|
||||
factory :awakening do
|
||||
sequence(:name_en) { |n| "Awakening #{n}" }
|
||||
name_jp { "覚醒" }
|
||||
object_type { "Character" }
|
||||
sequence(:slug) { |n| "awakening-#{n}" }
|
||||
order { 1 }
|
||||
|
||||
trait :for_character do
|
||||
object_type { "Character" }
|
||||
end
|
||||
|
||||
trait :for_weapon do
|
||||
object_type { "Weapon" }
|
||||
end
|
||||
|
||||
trait :for_summon do
|
||||
object_type { "Summon" }
|
||||
end
|
||||
end
|
||||
end
|
||||
51
spec/factories/characters.rb
Normal file
51
spec/factories/characters.rb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
FactoryBot.define do
|
||||
factory :character do
|
||||
sequence(:granblue_id) { |n| "304#{n.to_s.rjust(7, '0')}" }
|
||||
sequence(:name_en) { |n| "Test Character #{n}" }
|
||||
name_jp { "テストキャラクター" }
|
||||
rarity { 4 } # SSR
|
||||
element { 1 } # Fire
|
||||
race1 { 1 } # Human
|
||||
race2 { nil }
|
||||
gender { 0 } # Unknown
|
||||
|
||||
proficiency1 { 1 } # Sabre
|
||||
proficiency2 { nil }
|
||||
|
||||
# Max stats
|
||||
max_hp { 1500 }
|
||||
max_atk { 8000 }
|
||||
max_hp_flb { 1800 }
|
||||
max_atk_flb { 9600 }
|
||||
max_hp_ulb { nil }
|
||||
max_atk_ulb { nil }
|
||||
|
||||
# FLB and ULB capabilities
|
||||
flb { true }
|
||||
ulb { false }
|
||||
|
||||
release_date { 1.year.ago }
|
||||
|
||||
trait :r do
|
||||
rarity { 2 }
|
||||
max_hp { 800 }
|
||||
max_atk { 4000 }
|
||||
end
|
||||
|
||||
trait :sr do
|
||||
rarity { 3 }
|
||||
max_hp { 1200 }
|
||||
max_atk { 6000 }
|
||||
end
|
||||
|
||||
trait :ssr do
|
||||
rarity { 4 }
|
||||
end
|
||||
|
||||
trait :transcendable do
|
||||
ulb { true }
|
||||
max_hp_ulb { 2100 }
|
||||
max_atk_ulb { 11200 }
|
||||
end
|
||||
end
|
||||
end
|
||||
78
spec/factories/collection_characters.rb
Normal file
78
spec/factories/collection_characters.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
FactoryBot.define do
|
||||
factory :collection_character do
|
||||
association :user
|
||||
# Use the first character from canonical data or create one
|
||||
character { Character.first || association(:character) }
|
||||
uncap_level { 3 }
|
||||
transcendence_step { 0 }
|
||||
perpetuity { false }
|
||||
awakening { nil }
|
||||
awakening_level { 1 }
|
||||
|
||||
# Ring data with default nil values
|
||||
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 } }
|
||||
|
||||
# Trait for a fully uncapped character
|
||||
trait :max_uncap do
|
||||
uncap_level { 5 }
|
||||
end
|
||||
|
||||
# Trait for a transcended character (requires max uncap)
|
||||
trait :transcended do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 5 }
|
||||
end
|
||||
|
||||
# Trait for max transcendence
|
||||
trait :max_transcended do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 10 }
|
||||
end
|
||||
|
||||
# Trait for a character with awakening
|
||||
trait :with_awakening do
|
||||
after(:build) do |collection_character|
|
||||
# Create a character awakening if none exists
|
||||
collection_character.awakening = Awakening.where(object_type: 'Character').first ||
|
||||
FactoryBot.create(:awakening, object_type: 'Character')
|
||||
collection_character.awakening_level = 5
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for max awakening
|
||||
trait :max_awakening do
|
||||
after(:build) do |collection_character|
|
||||
collection_character.awakening = Awakening.where(object_type: 'Character').first ||
|
||||
FactoryBot.create(:awakening, object_type: 'Character')
|
||||
collection_character.awakening_level = 10
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for a character with rings
|
||||
trait :with_rings do
|
||||
ring1 { { modifier: 1, strength: 10.5 } }
|
||||
ring2 { { modifier: 2, strength: 8.0 } }
|
||||
ring3 { { modifier: 3, strength: 15.0 } }
|
||||
ring4 { { modifier: 4, strength: 12.5 } }
|
||||
end
|
||||
|
||||
# Trait for a character with earring
|
||||
trait :with_earring do
|
||||
earring { { modifier: 5, strength: 20.0 } }
|
||||
end
|
||||
|
||||
# Trait for a fully maxed character
|
||||
trait :maxed do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 10 }
|
||||
perpetuity { true }
|
||||
max_awakening
|
||||
with_rings
|
||||
with_earring
|
||||
end
|
||||
end
|
||||
end
|
||||
9
spec/factories/collection_job_accessories.rb
Normal file
9
spec/factories/collection_job_accessories.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
FactoryBot.define do
|
||||
factory :collection_job_accessory do
|
||||
association :user
|
||||
association :job_accessory
|
||||
|
||||
# Collection job accessories are simple - they either exist or don't
|
||||
# No uncap levels or other properties
|
||||
end
|
||||
end
|
||||
70
spec/factories/collection_summons.rb
Normal file
70
spec/factories/collection_summons.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
FactoryBot.define do
|
||||
factory :collection_summon do
|
||||
association :user
|
||||
association :summon
|
||||
uncap_level { 3 }
|
||||
transcendence_step { 0 }
|
||||
|
||||
# Trait for max uncap
|
||||
trait :max_uncap do
|
||||
uncap_level { 5 }
|
||||
end
|
||||
|
||||
# Trait for transcended summon
|
||||
trait :transcended do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 5 }
|
||||
after(:build) do |collection_summon|
|
||||
collection_summon.summon = FactoryBot.create(:summon, :transcendable)
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for max transcendence
|
||||
trait :max_transcended do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 10 }
|
||||
after(:build) do |collection_summon|
|
||||
collection_summon.summon = FactoryBot.create(:summon, :transcendable)
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for 0* summon (common for gacha summons)
|
||||
trait :no_uncap do
|
||||
uncap_level { 0 }
|
||||
end
|
||||
|
||||
# Trait for 1* summon
|
||||
trait :one_star do
|
||||
uncap_level { 1 }
|
||||
end
|
||||
|
||||
# Trait for 2* summon
|
||||
trait :two_star do
|
||||
uncap_level { 2 }
|
||||
end
|
||||
|
||||
# Trait for 3* summon (common stopping point)
|
||||
trait :three_star do
|
||||
uncap_level { 3 }
|
||||
end
|
||||
|
||||
# Trait for 4* summon (FLB)
|
||||
trait :four_star do
|
||||
uncap_level { 4 }
|
||||
end
|
||||
|
||||
# Trait for 5* summon (ULB)
|
||||
trait :five_star do
|
||||
uncap_level { 5 }
|
||||
end
|
||||
|
||||
# Trait for fully upgraded summon
|
||||
trait :maxed do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 10 }
|
||||
after(:build) do |collection_summon|
|
||||
collection_summon.summon = FactoryBot.create(:summon, :transcendable)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
100
spec/factories/collection_weapons.rb
Normal file
100
spec/factories/collection_weapons.rb
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
FactoryBot.define do
|
||||
factory :collection_weapon do
|
||||
association :user
|
||||
association :weapon
|
||||
uncap_level { 3 }
|
||||
transcendence_step { 0 }
|
||||
awakening { nil }
|
||||
awakening_level { 1 }
|
||||
element { nil } # Only used for element-changeable weapons
|
||||
|
||||
# AX skills
|
||||
ax_modifier1 { nil }
|
||||
ax_strength1 { nil }
|
||||
ax_modifier2 { nil }
|
||||
ax_strength2 { nil }
|
||||
|
||||
# Weapon keys
|
||||
weapon_key1 { nil }
|
||||
weapon_key2 { nil }
|
||||
weapon_key3 { nil }
|
||||
weapon_key4 { nil }
|
||||
|
||||
# Trait for max uncap
|
||||
trait :max_uncap do
|
||||
uncap_level { 5 }
|
||||
end
|
||||
|
||||
# Trait for transcended weapon
|
||||
trait :transcended do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 5 }
|
||||
after(:build) do |collection_weapon|
|
||||
collection_weapon.weapon = FactoryBot.create(:weapon, :transcendable)
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for max transcendence
|
||||
trait :max_transcended do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 10 }
|
||||
after(:build) do |collection_weapon|
|
||||
collection_weapon.weapon = FactoryBot.create(:weapon, :transcendable)
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for weapon with awakening
|
||||
trait :with_awakening do
|
||||
after(:build) do |collection_weapon|
|
||||
collection_weapon.awakening = Awakening.where(object_type: 'Weapon').first ||
|
||||
FactoryBot.create(:awakening, object_type: 'Weapon')
|
||||
collection_weapon.awakening_level = 5
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for weapon with keys
|
||||
trait :with_keys do
|
||||
after(:build) do |collection_weapon|
|
||||
# Create weapon keys that are compatible with any weapon
|
||||
collection_weapon.weapon_key1 = FactoryBot.create(:weapon_key, :universal_key)
|
||||
collection_weapon.weapon_key2 = FactoryBot.create(:weapon_key, :universal_key)
|
||||
collection_weapon.weapon_key3 = FactoryBot.create(:weapon_key, :universal_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for weapon with all 4 keys (Opus/Draconics)
|
||||
trait :with_four_keys do
|
||||
with_keys
|
||||
after(:build) do |collection_weapon|
|
||||
collection_weapon.weapon = FactoryBot.create(:weapon, :opus) # Opus weapon supports 4 keys
|
||||
collection_weapon.weapon_key4 = FactoryBot.create(:weapon_key, :universal_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Trait for AX weapon with skills
|
||||
trait :with_ax do
|
||||
ax_modifier1 { 1 } # Attack modifier
|
||||
ax_strength1 { 3.5 }
|
||||
ax_modifier2 { 2 } # HP modifier
|
||||
ax_strength2 { 10.0 }
|
||||
end
|
||||
|
||||
# Trait for element-changed weapon (Revans weapons)
|
||||
trait :element_changed do
|
||||
element { rand(0..5) } # Random element 0-5
|
||||
end
|
||||
|
||||
# Trait for fully upgraded weapon
|
||||
trait :maxed do
|
||||
uncap_level { 5 }
|
||||
transcendence_step { 10 }
|
||||
with_keys
|
||||
after(:build) do |collection_weapon|
|
||||
collection_weapon.weapon = FactoryBot.create(:weapon, :transcendable)
|
||||
collection_weapon.awakening = Awakening.where(object_type: 'Weapon').first ||
|
||||
FactoryBot.create(:awakening, object_type: 'Weapon')
|
||||
collection_weapon.awakening_level = 10
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
20
spec/factories/job_accessories.rb
Normal file
20
spec/factories/job_accessories.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
FactoryBot.define do
|
||||
factory :job_accessory do
|
||||
association :job
|
||||
sequence(:name_en) { |n| "Job Accessory #{n}" }
|
||||
name_jp { "ジョブアクセサリー" }
|
||||
sequence(:granblue_id) { |n| "1#{n.to_s.rjust(8, '0')}" }
|
||||
|
||||
trait :for_warrior do
|
||||
after(:build) do |accessory|
|
||||
accessory.job = Job.where(name_en: 'Warrior').first || FactoryBot.create(:job, name_en: 'Warrior')
|
||||
end
|
||||
end
|
||||
|
||||
trait :for_sage do
|
||||
after(:build) do |accessory|
|
||||
accessory.job = Job.where(name_en: 'Sage').first || FactoryBot.create(:job, name_en: 'Sage')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
spec/factories/jobs.rb
Normal file
17
spec/factories/jobs.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FactoryBot.define do
|
||||
factory :job do
|
||||
sequence(:name_en) { |n| "Test Job #{n}" }
|
||||
name_jp { "テストジョブ" }
|
||||
sequence(:granblue_id) { |n| "3#{n.to_s.rjust(8, '0')}" }
|
||||
row { 4 } # Row IV
|
||||
master_level { 30 }
|
||||
order { 1 }
|
||||
|
||||
proficiency1 { 1 } # Sabre
|
||||
proficiency2 { 2 } # Dagger
|
||||
|
||||
accessory { true }
|
||||
accessory_type { 1 }
|
||||
ultimate_mastery { true }
|
||||
end
|
||||
end
|
||||
58
spec/factories/summons.rb
Normal file
58
spec/factories/summons.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
FactoryBot.define do
|
||||
factory :summon do
|
||||
sequence(:granblue_id) { |n| "204#{n.to_s.rjust(7, '0')}" }
|
||||
sequence(:name_en) { |n| "Test Summon #{n}" }
|
||||
name_jp { "テスト召喚石" }
|
||||
rarity { 4 } # SSR
|
||||
element { 1 } # Fire
|
||||
|
||||
# Release info
|
||||
release_date { 1.year.ago }
|
||||
flb_date { 6.months.ago }
|
||||
ulb_date { nil }
|
||||
transcendence_date { nil }
|
||||
|
||||
# Max stats
|
||||
max_hp { 500 }
|
||||
max_atk { 2000 }
|
||||
max_hp_flb { 600 }
|
||||
max_atk_flb { 2400 }
|
||||
max_hp_ulb { nil }
|
||||
max_atk_ulb { nil }
|
||||
|
||||
# Capabilities
|
||||
flb { true }
|
||||
ulb { false }
|
||||
transcendence { false }
|
||||
|
||||
trait :r do
|
||||
rarity { 2 }
|
||||
max_hp { 200 }
|
||||
max_atk { 800 }
|
||||
max_hp_flb { nil }
|
||||
max_atk_flb { nil }
|
||||
flb { false }
|
||||
end
|
||||
|
||||
trait :sr do
|
||||
rarity { 3 }
|
||||
max_hp { 350 }
|
||||
max_atk { 1400 }
|
||||
end
|
||||
|
||||
trait :ssr do
|
||||
rarity { 4 }
|
||||
end
|
||||
|
||||
trait :transcendable do
|
||||
ulb { true }
|
||||
transcendence { true }
|
||||
ulb_date { 3.months.ago }
|
||||
transcendence_date { 1.month.ago }
|
||||
max_hp_ulb { 700 }
|
||||
max_atk_ulb { 2800 }
|
||||
max_hp_xlb { 800 }
|
||||
max_atk_xlb { 3200 }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,24 @@
|
|||
FactoryBot.define do
|
||||
factory :weapon_key do
|
||||
|
||||
sequence(:name_en) { |n| "Pendulum of #{['Strife', 'Sagacity', 'Prosperity', 'Forbiddance', 'Temperament'].sample}" }
|
||||
name_jp { "鍵" }
|
||||
slot { rand(1..3) }
|
||||
group { rand(1..5) }
|
||||
order { rand(1..20) }
|
||||
sequence(:slug) { |n| "key-#{n}" }
|
||||
sequence(:granblue_id) { |n| n.to_s }
|
||||
series { [3, 27] } # Opus and Draconic weapons
|
||||
|
||||
trait :opus_key do
|
||||
series { [3] }
|
||||
end
|
||||
|
||||
trait :draconic_key do
|
||||
series { [27] }
|
||||
end
|
||||
|
||||
trait :universal_key do
|
||||
series { [3, 27, 99] } # Works with more weapon series
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,79 @@
|
|||
FactoryBot.define do
|
||||
factory :weapon do
|
||||
|
||||
sequence(:granblue_id) { |n| "104#{n.to_s.rjust(7, '0')}" }
|
||||
sequence(:name_en) { |n| "Test Weapon #{n}" }
|
||||
name_jp { "テスト武器" }
|
||||
rarity { 4 } # SSR
|
||||
element { 1 } # Fire
|
||||
proficiency { 1 } # Sabre
|
||||
series { 99 } # Gacha
|
||||
|
||||
# Release info
|
||||
release_date { 1.year.ago }
|
||||
flb_date { 6.months.ago }
|
||||
ulb_date { nil }
|
||||
transcendence_date { nil }
|
||||
|
||||
# Max stats
|
||||
max_hp { 300 }
|
||||
max_atk { 2400 }
|
||||
max_hp_flb { 360 }
|
||||
max_atk_flb { 2900 }
|
||||
max_hp_ulb { nil }
|
||||
max_atk_ulb { nil }
|
||||
|
||||
# Capabilities
|
||||
flb { true }
|
||||
ulb { false }
|
||||
transcendence { false }
|
||||
ax { false }
|
||||
|
||||
# Skill info
|
||||
max_skill_level { 15 }
|
||||
max_level { 150 }
|
||||
|
||||
trait :r do
|
||||
rarity { 2 }
|
||||
max_hp { 120 }
|
||||
max_atk { 960 }
|
||||
max_hp_flb { nil }
|
||||
max_atk_flb { nil }
|
||||
flb { false }
|
||||
end
|
||||
|
||||
trait :sr do
|
||||
rarity { 3 }
|
||||
max_hp { 200 }
|
||||
max_atk { 1600 }
|
||||
max_hp_flb { 240 }
|
||||
max_atk_flb { 1920 }
|
||||
end
|
||||
|
||||
trait :ssr do
|
||||
rarity { 4 }
|
||||
end
|
||||
|
||||
trait :transcendable do
|
||||
ulb { true }
|
||||
transcendence { true }
|
||||
ulb_date { 3.months.ago }
|
||||
transcendence_date { 1.month.ago }
|
||||
max_hp_ulb { 420 }
|
||||
max_atk_ulb { 3400 }
|
||||
max_level { 200 }
|
||||
max_skill_level { 20 }
|
||||
end
|
||||
|
||||
trait :opus do
|
||||
series { 3 } # dark-opus
|
||||
end
|
||||
|
||||
trait :draconic do
|
||||
series { 27 } # draconic
|
||||
end
|
||||
|
||||
trait :ax_weapon do
|
||||
ax { true }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
194
spec/models/collection_character_spec.rb
Normal file
194
spec/models/collection_character_spec.rb
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CollectionCharacter, type: :model do
|
||||
describe 'associations' do
|
||||
it { should belong_to(:user) }
|
||||
it { should belong_to(:character) }
|
||||
it { should belong_to(:awakening).optional }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
let(:user) { create(:user) }
|
||||
let(:character) { create(:character) }
|
||||
|
||||
subject { build(:collection_character, user: user, character: character) }
|
||||
|
||||
describe 'basic validations' do
|
||||
it { should validate_inclusion_of(:uncap_level).in_range(0..5) }
|
||||
it { should validate_inclusion_of(:transcendence_step).in_range(0..10) }
|
||||
it { should validate_inclusion_of(:awakening_level).in_range(1..10) }
|
||||
end
|
||||
|
||||
describe 'uniqueness validation' do
|
||||
it { should validate_uniqueness_of(:character_id).scoped_to(:user_id).ignoring_case_sensitivity.with_message('already exists in your collection') }
|
||||
end
|
||||
|
||||
describe 'ring validations' do
|
||||
context 'when ring has only modifier' do
|
||||
it 'is invalid' do
|
||||
collection_char = build(:collection_character, ring1: { modifier: 1, strength: nil })
|
||||
expect(collection_char).not_to be_valid
|
||||
expect(collection_char.errors[:base]).to include('Ring 1 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ring has only strength' do
|
||||
it 'is invalid' do
|
||||
collection_char = build(:collection_character, ring2: { modifier: nil, strength: 10.5 })
|
||||
expect(collection_char).not_to be_valid
|
||||
expect(collection_char.errors[:base]).to include('Ring 2 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ring has both modifier and strength' do
|
||||
it 'is valid' do
|
||||
collection_char = build(:collection_character, ring1: { modifier: 1, strength: 10.5 })
|
||||
expect(collection_char).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ring has neither modifier nor strength' do
|
||||
it 'is valid' do
|
||||
collection_char = build(:collection_character, ring1: { modifier: nil, strength: nil })
|
||||
expect(collection_char).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when earring has invalid data' do
|
||||
it 'validates earring like rings' do
|
||||
collection_char = build(:collection_character, earring: { modifier: 5, strength: nil })
|
||||
expect(collection_char).not_to be_valid
|
||||
expect(collection_char.errors[:base]).to include('Ring 5 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'awakening validations' do
|
||||
context 'when awakening is for wrong object type' do
|
||||
it 'is invalid' do
|
||||
weapon_awakening = create(:awakening, object_type: 'Weapon')
|
||||
collection_char = build(:collection_character, awakening: weapon_awakening)
|
||||
expect(collection_char).not_to be_valid
|
||||
expect(collection_char.errors[:awakening]).to include('must be a character awakening')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when awakening is for correct object type' do
|
||||
it 'is valid' do
|
||||
char_awakening = create(:awakening, object_type: 'Character')
|
||||
collection_char = build(:collection_character, awakening: char_awakening)
|
||||
expect(collection_char).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when awakening_level > 1 without awakening' do
|
||||
it 'is invalid' do
|
||||
collection_char = build(:collection_character, awakening: nil, awakening_level: 5)
|
||||
expect(collection_char).not_to be_valid
|
||||
expect(collection_char.errors[:awakening_level]).to include('cannot be set without an awakening')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when awakening_level is 1 without awakening' do
|
||||
it 'is valid' do
|
||||
collection_char = build(:collection_character, awakening: nil, awakening_level: 1)
|
||||
expect(collection_char).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'transcendence validations' do
|
||||
context 'when transcendence_step > 0 with uncap_level < 5' do
|
||||
it 'is invalid' do
|
||||
collection_char = build(:collection_character, uncap_level: 4, transcendence_step: 1)
|
||||
expect(collection_char).not_to be_valid
|
||||
expect(collection_char.errors[:transcendence_step]).to include('requires uncap level 5 (current: 4)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcendence_step > 0 with uncap_level = 5' do
|
||||
it 'is valid' do
|
||||
collection_char = build(:collection_character, uncap_level: 5, transcendence_step: 5)
|
||||
expect(collection_char).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcendence_step = 0 with any uncap_level' do
|
||||
it 'is valid' do
|
||||
collection_char = build(:collection_character, uncap_level: 3, transcendence_step: 0)
|
||||
expect(collection_char).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:fire_char) { create(:character, element: 0) }
|
||||
let!(:water_char) { create(:character, element: 1) }
|
||||
let!(:ssr_char) { create(:character, rarity: 4) }
|
||||
let!(:sr_char) { create(:character, rarity: 3) }
|
||||
|
||||
let!(:fire_collection) { create(:collection_character, character: fire_char) }
|
||||
let!(:water_collection) { create(:collection_character, character: water_char) }
|
||||
let!(:ssr_collection) { create(:collection_character, character: ssr_char) }
|
||||
let!(:sr_collection) { create(:collection_character, character: sr_char) }
|
||||
let!(:transcended) { create(:collection_character, :transcended) }
|
||||
let!(:awakened) { create(:collection_character, :with_awakening) }
|
||||
|
||||
describe '.by_element' do
|
||||
it 'returns characters of specified element' do
|
||||
expect(CollectionCharacter.by_element(0)).to include(fire_collection)
|
||||
expect(CollectionCharacter.by_element(0)).not_to include(water_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.by_rarity' do
|
||||
it 'returns characters of specified rarity' do
|
||||
expect(CollectionCharacter.by_rarity(4)).to include(ssr_collection)
|
||||
expect(CollectionCharacter.by_rarity(4)).not_to include(sr_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.transcended' do
|
||||
it 'returns only transcended characters' do
|
||||
expect(CollectionCharacter.transcended).to include(transcended)
|
||||
expect(CollectionCharacter.transcended).not_to include(fire_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.with_awakening' do
|
||||
it 'returns only characters with awakening' do
|
||||
expect(CollectionCharacter.with_awakening).to include(awakened)
|
||||
expect(CollectionCharacter.with_awakening).not_to include(fire_collection)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory traits' do
|
||||
describe ':maxed trait' do
|
||||
it 'creates a fully upgraded character' do
|
||||
maxed = create(:collection_character, :maxed)
|
||||
|
||||
aggregate_failures do
|
||||
expect(maxed.uncap_level).to eq(5)
|
||||
expect(maxed.transcendence_step).to eq(10)
|
||||
expect(maxed.perpetuity).to be true
|
||||
expect(maxed.awakening).to be_present
|
||||
expect(maxed.awakening_level).to eq(10)
|
||||
expect(maxed.ring1['modifier']).to be_present
|
||||
expect(maxed.earring['modifier']).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ':transcended trait' do
|
||||
it 'creates a transcended character with proper uncap' do
|
||||
transcended = create(:collection_character, :transcended)
|
||||
|
||||
expect(transcended.uncap_level).to eq(5)
|
||||
expect(transcended.transcendence_step).to eq(5)
|
||||
expect(transcended).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
69
spec/models/collection_job_accessory_spec.rb
Normal file
69
spec/models/collection_job_accessory_spec.rb
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CollectionJobAccessory, type: :model do
|
||||
describe 'associations' do
|
||||
it { should belong_to(:user) }
|
||||
it { should belong_to(:job_accessory) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
let(:user) { create(:user) }
|
||||
let(:job) { create(:job) }
|
||||
let(:job_accessory) { create(:job_accessory, job: job) }
|
||||
|
||||
subject { build(:collection_job_accessory, user: user, job_accessory: job_accessory) }
|
||||
|
||||
describe 'uniqueness validation' do
|
||||
it { should validate_uniqueness_of(:job_accessory_id).scoped_to(:user_id).ignoring_case_sensitivity.with_message('already exists in your collection') }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:warrior_job) { create(:job, name_en: 'Warrior') }
|
||||
let!(:sage_job) { create(:job, name_en: 'Sage') }
|
||||
|
||||
let!(:warrior_accessory) { create(:job_accessory, job: warrior_job) }
|
||||
let!(:sage_accessory) { create(:job_accessory, job: sage_job) }
|
||||
|
||||
let!(:warrior_collection) { create(:collection_job_accessory, job_accessory: warrior_accessory) }
|
||||
let!(:sage_collection) { create(:collection_job_accessory, job_accessory: sage_accessory) }
|
||||
|
||||
describe '.by_job' do
|
||||
it 'returns accessories for specified job' do
|
||||
expect(CollectionJobAccessory.by_job(warrior_job.id)).to include(warrior_collection)
|
||||
expect(CollectionJobAccessory.by_job(warrior_job.id)).not_to include(sage_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.by_job_accessory' do
|
||||
it 'returns collection entries for specified accessory' do
|
||||
expect(CollectionJobAccessory.by_job_accessory(warrior_accessory.id)).to include(warrior_collection)
|
||||
expect(CollectionJobAccessory.by_job_accessory(warrior_accessory.id)).not_to include(sage_collection)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'creates a valid collection job accessory' do
|
||||
collection_accessory = build(:collection_job_accessory)
|
||||
expect(collection_accessory).to be_valid
|
||||
end
|
||||
|
||||
it 'creates with associations' do
|
||||
collection_accessory = create(:collection_job_accessory)
|
||||
|
||||
aggregate_failures do
|
||||
expect(collection_accessory.user).to be_present
|
||||
expect(collection_accessory.job_accessory).to be_present
|
||||
expect(collection_accessory.job_accessory.job).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'blueprint' do
|
||||
it 'returns the correct blueprint class' do
|
||||
collection_accessory = build(:collection_job_accessory)
|
||||
expect(collection_accessory.blueprint).to eq(Api::V1::CollectionJobAccessoryBlueprint)
|
||||
end
|
||||
end
|
||||
end
|
||||
131
spec/models/collection_summon_spec.rb
Normal file
131
spec/models/collection_summon_spec.rb
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CollectionSummon, type: :model do
|
||||
describe 'associations' do
|
||||
it { should belong_to(:user) }
|
||||
it { should belong_to(:summon) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
let(:user) { create(:user) }
|
||||
let(:summon) { create(:summon) }
|
||||
|
||||
subject { build(:collection_summon, user: user, summon: summon) }
|
||||
|
||||
describe 'basic validations' do
|
||||
it { should validate_inclusion_of(:uncap_level).in_range(0..5) }
|
||||
it { should validate_inclusion_of(:transcendence_step).in_range(0..10) }
|
||||
end
|
||||
|
||||
describe 'transcendence validations' do
|
||||
context 'when transcendence_step > 0 with uncap_level < 5' do
|
||||
it 'is invalid' do
|
||||
collection_summon = build(:collection_summon, uncap_level: 4, transcendence_step: 1)
|
||||
expect(collection_summon).not_to be_valid
|
||||
expect(collection_summon.errors[:transcendence_step]).to include('requires uncap level 5 (current: 4)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcendence_step > 0 with uncap_level = 5' do
|
||||
it 'is valid for transcendable summon' do
|
||||
transcendable_summon = create(:summon, :transcendable)
|
||||
collection_summon = build(:collection_summon, summon: transcendable_summon, uncap_level: 5, transcendence_step: 5)
|
||||
expect(collection_summon).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcendence_step = 0 with any uncap_level' do
|
||||
it 'is valid' do
|
||||
collection_summon = build(:collection_summon, uncap_level: 3, transcendence_step: 0)
|
||||
expect(collection_summon).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcendence_step > 0 for non-transcendable summon' do
|
||||
it 'is invalid' do
|
||||
non_trans_summon = create(:summon, transcendence: false)
|
||||
collection_summon = build(:collection_summon, summon: non_trans_summon, uncap_level: 5, transcendence_step: 1)
|
||||
expect(collection_summon).not_to be_valid
|
||||
expect(collection_summon.errors[:transcendence_step]).to include('not available for this summon')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:fire_summon) { create(:summon, element: 0) }
|
||||
let!(:water_summon) { create(:summon, element: 1) }
|
||||
let!(:ssr_summon) { create(:summon, rarity: 4) }
|
||||
let!(:sr_summon) { create(:summon, rarity: 3) }
|
||||
|
||||
let!(:fire_collection) { create(:collection_summon, summon: fire_summon) }
|
||||
let!(:water_collection) { create(:collection_summon, summon: water_summon) }
|
||||
let!(:ssr_collection) { create(:collection_summon, summon: ssr_summon) }
|
||||
let!(:sr_collection) { create(:collection_summon, summon: sr_summon) }
|
||||
let!(:transcended) { create(:collection_summon, :transcended) }
|
||||
|
||||
describe '.by_element' do
|
||||
it 'returns summons of specified element' do
|
||||
expect(CollectionSummon.by_element(0)).to include(fire_collection)
|
||||
expect(CollectionSummon.by_element(0)).not_to include(water_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.by_rarity' do
|
||||
it 'returns summons of specified rarity' do
|
||||
expect(CollectionSummon.by_rarity(4)).to include(ssr_collection)
|
||||
expect(CollectionSummon.by_rarity(4)).not_to include(sr_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.transcended' do
|
||||
it 'returns only transcended summons' do
|
||||
expect(CollectionSummon.transcended).to include(transcended)
|
||||
expect(CollectionSummon.transcended).not_to include(fire_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.max_uncapped' do
|
||||
let!(:max_uncapped) { create(:collection_summon, uncap_level: 5) }
|
||||
let!(:partial_uncapped) { create(:collection_summon, uncap_level: 3) }
|
||||
|
||||
it 'returns only max uncapped summons' do
|
||||
expect(CollectionSummon.max_uncapped).to include(max_uncapped)
|
||||
expect(CollectionSummon.max_uncapped).not_to include(partial_uncapped)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory traits' do
|
||||
describe ':max_uncap trait' do
|
||||
it 'creates a max uncapped summon' do
|
||||
max_uncap = create(:collection_summon, :max_uncap)
|
||||
expect(max_uncap.uncap_level).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe ':transcended trait' do
|
||||
it 'creates a transcended summon' do
|
||||
transcended = create(:collection_summon, :transcended)
|
||||
|
||||
aggregate_failures do
|
||||
expect(transcended.uncap_level).to eq(5)
|
||||
expect(transcended.transcendence_step).to eq(5)
|
||||
expect(transcended).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ':max_transcended trait' do
|
||||
it 'creates a fully transcended summon' do
|
||||
max_transcended = create(:collection_summon, :max_transcended)
|
||||
|
||||
aggregate_failures do
|
||||
expect(max_transcended.uncap_level).to eq(5)
|
||||
expect(max_transcended.transcendence_step).to eq(10)
|
||||
expect(max_transcended).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
239
spec/models/collection_weapon_spec.rb
Normal file
239
spec/models/collection_weapon_spec.rb
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CollectionWeapon, type: :model do
|
||||
describe 'associations' do
|
||||
it { should belong_to(:user) }
|
||||
it { should belong_to(:weapon) }
|
||||
it { should belong_to(:awakening).optional }
|
||||
it { should belong_to(:weapon_key1).class_name('WeaponKey').optional }
|
||||
it { should belong_to(:weapon_key2).class_name('WeaponKey').optional }
|
||||
it { should belong_to(:weapon_key3).class_name('WeaponKey').optional }
|
||||
it { should belong_to(:weapon_key4).class_name('WeaponKey').optional }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
let(:user) { create(:user) }
|
||||
let(:weapon) { create(:weapon) }
|
||||
|
||||
subject { build(:collection_weapon, user: user, weapon: weapon) }
|
||||
|
||||
describe 'basic validations' do
|
||||
it { should validate_inclusion_of(:uncap_level).in_range(0..5) }
|
||||
it { should validate_inclusion_of(:transcendence_step).in_range(0..10) }
|
||||
it { should validate_inclusion_of(:awakening_level).in_range(1..10) }
|
||||
end
|
||||
|
||||
describe 'transcendence validations' do
|
||||
context 'when transcendence_step > 0 with uncap_level < 5' do
|
||||
it 'is invalid' do
|
||||
collection_weapon = build(:collection_weapon, uncap_level: 4, transcendence_step: 1)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:transcendence_step]).to include('requires uncap level 5 (current: 4)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcendence_step > 0 with uncap_level = 5' do
|
||||
it 'is valid for transcendable weapon' do
|
||||
transcendable_weapon = create(:weapon, :transcendable)
|
||||
collection_weapon = build(:collection_weapon, weapon: transcendable_weapon, uncap_level: 5, transcendence_step: 5)
|
||||
expect(collection_weapon).to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid for non-transcendable weapon' do
|
||||
non_transcendable_weapon = create(:weapon, transcendence: false)
|
||||
collection_weapon = build(:collection_weapon, weapon: non_transcendable_weapon, uncap_level: 5, transcendence_step: 5)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:transcendence_step]).to include('not available for this weapon')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'awakening validations' do
|
||||
context 'when awakening is for wrong object type' do
|
||||
it 'is invalid' do
|
||||
char_awakening = create(:awakening, object_type: 'Character')
|
||||
collection_weapon = build(:collection_weapon, awakening: char_awakening)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:awakening]).to include('must be a weapon awakening')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when awakening is for correct object type' do
|
||||
it 'is valid' do
|
||||
weapon_awakening = create(:awakening, object_type: 'Weapon')
|
||||
collection_weapon = build(:collection_weapon, awakening: weapon_awakening)
|
||||
expect(collection_weapon).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context 'when awakening_level > 1 without awakening' do
|
||||
it 'is invalid' do
|
||||
collection_weapon = build(:collection_weapon, awakening: nil, awakening_level: 5)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:awakening_level]).to include('cannot be set without an awakening')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'AX skill validations' do
|
||||
context 'when AX skill has only modifier' do
|
||||
it 'is invalid' do
|
||||
collection_weapon = build(:collection_weapon, ax_modifier1: 1, ax_strength1: nil)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:base]).to include('AX skill 1 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when AX skill has only strength' do
|
||||
it 'is invalid' do
|
||||
collection_weapon = build(:collection_weapon, ax_modifier2: nil, ax_strength2: 10.5)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:base]).to include('AX skill 2 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when AX skill has both modifier and strength' do
|
||||
it 'is valid' do
|
||||
collection_weapon = build(:collection_weapon, ax_modifier1: 1, ax_strength1: 3.5)
|
||||
expect(collection_weapon).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'weapon key validations' do
|
||||
let(:opus_weapon) { create(:weapon, :opus) }
|
||||
let(:draconic_weapon) { create(:weapon, :draconic) }
|
||||
let(:regular_weapon) { create(:weapon) }
|
||||
|
||||
context 'when weapon_key4 is set on non-Opus/Draconic weapon' do
|
||||
it 'is invalid' do
|
||||
key = create(:weapon_key)
|
||||
collection_weapon = build(:collection_weapon, weapon: regular_weapon, weapon_key4: key)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:weapon_key4]).to include('can only be set on Opus or Draconic weapons')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when weapon_key4 is set on Opus weapon' do
|
||||
it 'is valid' do
|
||||
key = create(:weapon_key)
|
||||
collection_weapon = build(:collection_weapon, weapon: opus_weapon, weapon_key4: key)
|
||||
expect(collection_weapon).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'element change validations' do
|
||||
context 'when element is set on non-element-changeable weapon' do
|
||||
it 'is invalid' do
|
||||
collection_weapon = build(:collection_weapon, element: 1)
|
||||
expect(collection_weapon).not_to be_valid
|
||||
expect(collection_weapon.errors[:element]).to include('can only be set on element-changeable weapons')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when element is set on Revenant weapon' do
|
||||
let(:revenant_weapon) { create(:weapon, series: 4) } # Revenant series (element-changeable)
|
||||
|
||||
it 'is valid' do
|
||||
collection_weapon = build(:collection_weapon, weapon: revenant_weapon, element: 2)
|
||||
expect(collection_weapon).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:fire_weapon) { create(:weapon, element: 0) }
|
||||
let!(:water_weapon) { create(:weapon, element: 1) }
|
||||
let!(:ssr_weapon) { create(:weapon, rarity: 4) }
|
||||
let!(:sr_weapon) { create(:weapon, rarity: 3) }
|
||||
|
||||
let!(:fire_collection) { create(:collection_weapon, weapon: fire_weapon) }
|
||||
let!(:water_collection) { create(:collection_weapon, weapon: water_weapon) }
|
||||
let!(:ssr_collection) { create(:collection_weapon, weapon: ssr_weapon) }
|
||||
let!(:sr_collection) { create(:collection_weapon, weapon: sr_weapon) }
|
||||
let!(:transcended) { create(:collection_weapon, :transcended) }
|
||||
let!(:with_awakening) { create(:collection_weapon, :with_awakening) }
|
||||
let!(:with_keys) { create(:collection_weapon, :with_keys) }
|
||||
|
||||
describe '.by_element' do
|
||||
it 'returns weapons of specified element' do
|
||||
expect(CollectionWeapon.by_element(0)).to include(fire_collection)
|
||||
expect(CollectionWeapon.by_element(0)).not_to include(water_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.by_rarity' do
|
||||
it 'returns weapons of specified rarity' do
|
||||
expect(CollectionWeapon.by_rarity(4)).to include(ssr_collection)
|
||||
expect(CollectionWeapon.by_rarity(4)).not_to include(sr_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.transcended' do
|
||||
it 'returns only transcended weapons' do
|
||||
expect(CollectionWeapon.transcended).to include(transcended)
|
||||
expect(CollectionWeapon.transcended).not_to include(fire_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.with_awakening' do
|
||||
it 'returns only weapons with awakening' do
|
||||
expect(CollectionWeapon.with_awakening).to include(with_awakening)
|
||||
expect(CollectionWeapon.with_awakening).not_to include(fire_collection)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.with_keys' do
|
||||
it 'returns only weapons with keys' do
|
||||
expect(CollectionWeapon.with_keys).to include(with_keys)
|
||||
expect(CollectionWeapon.with_keys).not_to include(fire_collection)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory traits' do
|
||||
describe ':maxed trait' do
|
||||
it 'creates a fully upgraded weapon' do
|
||||
maxed = create(:collection_weapon, :maxed)
|
||||
|
||||
aggregate_failures do
|
||||
expect(maxed.uncap_level).to eq(5)
|
||||
expect(maxed.transcendence_step).to eq(10)
|
||||
expect(maxed.awakening).to be_present
|
||||
expect(maxed.awakening_level).to eq(10)
|
||||
expect(maxed.weapon_key1).to be_present
|
||||
expect(maxed.weapon_key2).to be_present
|
||||
expect(maxed.weapon_key3).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ':with_ax trait' do
|
||||
it 'creates a weapon with AX skills' do
|
||||
ax_weapon = create(:collection_weapon, :with_ax)
|
||||
|
||||
aggregate_failures do
|
||||
expect(ax_weapon.ax_modifier1).to eq(1)
|
||||
expect(ax_weapon.ax_strength1).to eq(3.5)
|
||||
expect(ax_weapon.ax_modifier2).to eq(2)
|
||||
expect(ax_weapon.ax_strength2).to eq(10.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ':with_four_keys trait' do
|
||||
it 'creates a weapon with all four keys' do
|
||||
four_key_weapon = create(:collection_weapon, :with_four_keys)
|
||||
|
||||
aggregate_failures do
|
||||
expect(four_key_weapon.weapon_key1).to be_present
|
||||
expect(four_key_weapon.weapon_key2).to be_present
|
||||
expect(four_key_weapon.weapon_key3).to be_present
|
||||
expect(four_key_weapon.weapon_key4).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,135 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe User, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
describe 'associations' do
|
||||
it { should have_many(:parties).dependent(:destroy) }
|
||||
it { should have_many(:favorites).dependent(:destroy) }
|
||||
it { should have_many(:collection_characters).dependent(:destroy) }
|
||||
it { should have_many(:collection_weapons).dependent(:destroy) }
|
||||
it { should have_many(:collection_summons).dependent(:destroy) }
|
||||
it { should have_many(:collection_job_accessories).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { should validate_presence_of(:username) }
|
||||
it { should validate_length_of(:username).is_at_least(3).is_at_most(26) }
|
||||
it { should validate_presence_of(:email) }
|
||||
it { should validate_uniqueness_of(:email).ignoring_case_sensitivity }
|
||||
end
|
||||
|
||||
describe 'collection_privacy enum' do
|
||||
it { should define_enum_for(:collection_privacy).with_values(everyone: 0, crew_only: 1, private_collection: 2).with_prefix(true) }
|
||||
|
||||
it 'defaults to everyone' do
|
||||
user = build(:user)
|
||||
expect(user.collection_privacy).to eq('everyone')
|
||||
end
|
||||
|
||||
it 'allows setting to crew_only' do
|
||||
user = create(:user, collection_privacy: :crew_only)
|
||||
expect(user.collection_privacy).to eq('crew_only')
|
||||
end
|
||||
|
||||
it 'allows setting to private_collection' do
|
||||
user = create(:user, collection_privacy: :private_collection)
|
||||
expect(user.collection_privacy).to eq('private_collection')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#collection_viewable_by?' do
|
||||
let(:owner) { create(:user, collection_privacy: :everyone) }
|
||||
let(:viewer) { create(:user) }
|
||||
|
||||
context 'when viewer is the owner' do
|
||||
it 'returns true regardless of privacy setting' do
|
||||
owner.update(collection_privacy: :private_collection)
|
||||
expect(owner.collection_viewable_by?(owner)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when collection privacy is everyone' do
|
||||
it 'returns true for any viewer' do
|
||||
owner.update(collection_privacy: :everyone)
|
||||
expect(owner.collection_viewable_by?(viewer)).to be true
|
||||
end
|
||||
|
||||
it 'returns true for unauthenticated users (nil)' do
|
||||
owner.update(collection_privacy: :everyone)
|
||||
expect(owner.collection_viewable_by?(nil)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when collection privacy is private_collection' do
|
||||
it 'returns false for non-owner' do
|
||||
owner.update(collection_privacy: :private_collection)
|
||||
expect(owner.collection_viewable_by?(viewer)).to be false
|
||||
end
|
||||
|
||||
it 'returns false for unauthenticated users' do
|
||||
owner.update(collection_privacy: :private_collection)
|
||||
expect(owner.collection_viewable_by?(nil)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when collection privacy is crew_only' do
|
||||
it 'returns false for non-owner (crews not yet implemented)' do
|
||||
owner.update(collection_privacy: :crew_only)
|
||||
expect(owner.collection_viewable_by?(viewer)).to be false
|
||||
end
|
||||
|
||||
it 'returns false for unauthenticated users' do
|
||||
owner.update(collection_privacy: :crew_only)
|
||||
expect(owner.collection_viewable_by?(nil)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#in_same_crew_as?' do
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
|
||||
it 'returns false (placeholder until crews are implemented)' do
|
||||
expect(user1.in_same_crew_as?(user2)).to be false
|
||||
end
|
||||
|
||||
it 'returns false when other_user is present' do
|
||||
expect(user1.in_same_crew_as?(user2)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'collection associations behavior' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'destroys collection_characters when user is destroyed' do
|
||||
create(:collection_character, user: user)
|
||||
expect { user.destroy }.to change(CollectionCharacter, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'destroys collection_weapons when user is destroyed' do
|
||||
create(:collection_weapon, user: user)
|
||||
expect { user.destroy }.to change(CollectionWeapon, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'destroys collection_summons when user is destroyed' do
|
||||
create(:collection_summon, user: user)
|
||||
expect { user.destroy }.to change(CollectionSummon, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'destroys collection_job_accessories when user is destroyed' do
|
||||
create(:collection_job_accessory, user: user)
|
||||
expect { user.destroy }.to change(CollectionJobAccessory, :count).by(-1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#admin?' do
|
||||
it 'returns true when role is 9' do
|
||||
user = create(:user, role: 9)
|
||||
expect(user.admin?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when role is not 9' do
|
||||
user = create(:user, role: 0)
|
||||
expect(user.admin?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
258
spec/requests/collection_characters_controller_spec.rb
Normal file
258
spec/requests/collection_characters_controller_spec.rb
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Collection Characters API', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_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
|
||||
|
||||
let(:character) { create(:character) }
|
||||
let(:awakening) { create(:awakening, object_type: 'Character') }
|
||||
|
||||
describe 'GET /api/v1/collection_characters' do
|
||||
let(:character1) { create(:character) }
|
||||
let(:character2) { create(:character) }
|
||||
let!(:collection_character1) { create(:collection_character, user: user, character: character1, uncap_level: 5) }
|
||||
let!(:collection_character2) { create(:collection_character, user: user, character: character2, uncap_level: 3) }
|
||||
let!(:other_user_character) { create(:collection_character, user: other_user) }
|
||||
|
||||
it 'returns the current user\'s collection characters' do
|
||||
get '/api/v1/collection/characters', headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['characters'].length).to eq(2)
|
||||
expect(json['meta']['count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'supports pagination' do
|
||||
get '/api/v1/collection/characters', params: { page: 1, limit: 1 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['characters'].length).to eq(1)
|
||||
expect(json['meta']['total_pages']).to be >= 2
|
||||
end
|
||||
|
||||
it 'supports filtering by element' do
|
||||
fire_character = create(:character, element: 0)
|
||||
create(:collection_character, user: user, character: fire_character)
|
||||
|
||||
get '/api/v1/collection/characters', params: { element: 0 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
characters = json['characters']
|
||||
expect(characters.all? { |c| c['character']['element'] == 0 }).to be true
|
||||
end
|
||||
|
||||
it 'supports filtering by rarity' do
|
||||
ssr_character = create(:character, rarity: 4)
|
||||
create(:collection_character, user: user, character: ssr_character)
|
||||
|
||||
get '/api/v1/collection/characters', params: { rarity: 4 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
characters = json['characters']
|
||||
expect(characters.all? { |c| c['character']['rarity'] == 4 }).to be true
|
||||
end
|
||||
|
||||
it 'supports filtering by both element and rarity' do
|
||||
fire_ssr = create(:character, element: 0, rarity: 4)
|
||||
water_ssr = create(:character, element: 1, rarity: 4)
|
||||
fire_sr = create(:character, element: 0, rarity: 3)
|
||||
|
||||
create(:collection_character, user: user, character: fire_ssr)
|
||||
create(:collection_character, user: user, character: water_ssr)
|
||||
create(:collection_character, user: user, character: fire_sr)
|
||||
|
||||
get '/api/v1/collection/characters', params: { element: 0, rarity: 4 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
characters = json['characters']
|
||||
expect(characters.length).to eq(1)
|
||||
expect(characters.first['character']['element']).to eq(0)
|
||||
expect(characters.first['character']['rarity']).to eq(4)
|
||||
end
|
||||
|
||||
it 'returns empty array when filters match nothing' do
|
||||
fire_character = create(:character, element: 0, rarity: 4)
|
||||
create(:collection_character, user: user, character: fire_character)
|
||||
|
||||
get '/api/v1/collection/characters', params: { element: 1, rarity: 3 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['characters']).to be_empty
|
||||
end
|
||||
|
||||
it 'returns unauthorized without authentication' do
|
||||
get '/api/v1/collection/characters'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/collection_characters/:id' do
|
||||
let!(:collection_character) { create(:collection_character, user: user, character: character) }
|
||||
|
||||
it 'returns the collection character' do
|
||||
get "/api/v1/collection/characters/#{collection_character.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['id']).to eq(collection_character.id)
|
||||
expect(json['character']['id']).to eq(character.id)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s character' do
|
||||
other_collection = create(:collection_character, user: other_user)
|
||||
get "/api/v1/collection/characters/#{other_collection.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent character' do
|
||||
get "/api/v1/collection/characters/#{SecureRandom.uuid}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/collection_characters' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
collection_character: {
|
||||
character_id: character.id,
|
||||
uncap_level: 3,
|
||||
transcendence_step: 0,
|
||||
perpetuity: false,
|
||||
awakening_id: awakening.id,
|
||||
awakening_level: 5
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new collection character' do
|
||||
expect do
|
||||
post '/api/v1/collection/characters', params: valid_attributes.to_json, headers: headers
|
||||
end.to change(CollectionCharacter, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['character']['id']).to eq(character.id)
|
||||
expect(json['uncap_level']).to eq(3)
|
||||
end
|
||||
|
||||
it 'returns error when character already in collection' do
|
||||
create(:collection_character, user: user, character: character)
|
||||
|
||||
post '/api/v1/collection/characters', params: valid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['error']['message']).to include('already exists in your collection')
|
||||
end
|
||||
|
||||
it 'returns error with invalid awakening type' do
|
||||
weapon_awakening = create(:awakening, object_type: 'Weapon')
|
||||
invalid_attributes = valid_attributes.deep_merge(
|
||||
collection_character: { awakening_id: weapon_awakening.id }
|
||||
)
|
||||
|
||||
post '/api/v1/collection/characters', params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('must be a character awakening')
|
||||
end
|
||||
|
||||
it 'returns error with invalid transcendence' do
|
||||
invalid_attributes = valid_attributes.deep_merge(
|
||||
collection_character: { uncap_level: 3, transcendence_step: 5 }
|
||||
)
|
||||
|
||||
post '/api/v1/collection/characters', params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('requires uncap level 5')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/collection_characters/:id' do
|
||||
let!(:collection_character) { create(:collection_character, user: user, character: character, uncap_level: 3) }
|
||||
|
||||
let(:update_attributes) do
|
||||
{
|
||||
collection_character: {
|
||||
uncap_level: 5,
|
||||
transcendence_step: 3,
|
||||
ring1: { modifier: 1, strength: 12.5 }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the collection character' do
|
||||
put "/api/v1/collection/characters/#{collection_character.id}",
|
||||
params: update_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['uncap_level']).to eq(5)
|
||||
expect(json['transcendence_step']).to eq(3)
|
||||
expect(json['ring1']['modifier']).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s character' do
|
||||
other_collection = create(:collection_character, user: other_user)
|
||||
put "/api/v1/collection/characters/#{other_collection.id}",
|
||||
params: update_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns error with invalid ring data' do
|
||||
invalid_attributes = {
|
||||
collection_character: {
|
||||
ring1: { modifier: 1, strength: nil } # Invalid: missing strength
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/collection/characters/#{collection_character.id}",
|
||||
params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('Ring 1 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/collection_characters/:id' do
|
||||
let!(:collection_character) { create(:collection_character, user: user, character: character) }
|
||||
|
||||
it 'deletes the collection character' do
|
||||
expect do
|
||||
delete "/api/v1/collection/characters/#{collection_character.id}", headers: headers
|
||||
end.to change(CollectionCharacter, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s character' do
|
||||
other_collection = create(:collection_character, user: other_user)
|
||||
|
||||
expect do
|
||||
delete "/api/v1/collection/characters/#{other_collection.id}", headers: headers
|
||||
end.not_to change(CollectionCharacter, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
166
spec/requests/collection_controller_spec.rb
Normal file
166
spec/requests/collection_controller_spec.rb
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Collection Viewing API', type: :request do
|
||||
let(:user) { create(:user, collection_privacy: 'everyone') }
|
||||
let(:private_user) { create(:user, collection_privacy: 'private_collection') }
|
||||
let(:crew_only_user) { create(:user, collection_privacy: 'crew_only') }
|
||||
let(:viewer) { create(:user) }
|
||||
|
||||
let(:access_token) do
|
||||
Doorkeeper::AccessToken.create!(resource_owner_id: viewer.id, expires_in: 30.days, scopes: 'public')
|
||||
end
|
||||
let(:headers) do
|
||||
{ 'Authorization' => "Bearer #{access_token.token}", 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
# Create some collection items for testing
|
||||
before do
|
||||
# User's collection
|
||||
create(:collection_character, user: user)
|
||||
create(:collection_weapon, user: user)
|
||||
create(:collection_summon, user: user)
|
||||
create(:collection_job_accessory, user: user)
|
||||
|
||||
# Private user's collection
|
||||
create(:collection_character, user: private_user)
|
||||
create(:collection_weapon, user: private_user)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/users/:user_id/collection' do
|
||||
context 'viewing public collection' do
|
||||
it 'returns the complete collection' do
|
||||
get "/api/v1/users/#{user.id}/collection", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('characters')
|
||||
expect(json).to have_key('weapons')
|
||||
expect(json).to have_key('summons')
|
||||
expect(json).to have_key('job_accessories')
|
||||
expect(json['characters'].length).to eq(1)
|
||||
expect(json['weapons'].length).to eq(1)
|
||||
expect(json['summons'].length).to eq(1)
|
||||
expect(json['job_accessories'].length).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns only characters when type=characters' do
|
||||
get "/api/v1/users/#{user.id}/collection", params: { type: 'characters' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('characters')
|
||||
expect(json).not_to have_key('weapons')
|
||||
expect(json).not_to have_key('summons')
|
||||
expect(json['characters'].length).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns only weapons when type=weapons' do
|
||||
get "/api/v1/users/#{user.id}/collection", params: { type: 'weapons' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('weapons')
|
||||
expect(json).not_to have_key('characters')
|
||||
expect(json).not_to have_key('summons')
|
||||
expect(json['weapons'].length).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns only summons when type=summons' do
|
||||
get "/api/v1/users/#{user.id}/collection", params: { type: 'summons' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('summons')
|
||||
expect(json).not_to have_key('characters')
|
||||
expect(json).not_to have_key('weapons')
|
||||
expect(json['summons'].length).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns only job accessories when type=job_accessories' do
|
||||
get "/api/v1/users/#{user.id}/collection", params: { type: 'job_accessories' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('job_accessories')
|
||||
expect(json).not_to have_key('characters')
|
||||
expect(json).not_to have_key('weapons')
|
||||
expect(json['job_accessories'].length).to eq(1)
|
||||
end
|
||||
|
||||
it 'works without authentication for public collections' do
|
||||
get "/api/v1/users/#{user.id}/collection"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('characters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'viewing private collection' do
|
||||
it 'returns forbidden for non-owner' do
|
||||
get "/api/v1/users/#{private_user.id}/collection", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['error']).to include('do not have permission')
|
||||
end
|
||||
|
||||
it 'allows owner to view their own private collection' do
|
||||
owner_token = Doorkeeper::AccessToken.create!(
|
||||
resource_owner_id: private_user.id,
|
||||
expires_in: 30.days,
|
||||
scopes: 'public'
|
||||
)
|
||||
owner_headers = {
|
||||
'Authorization' => "Bearer #{owner_token.token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
|
||||
get "/api/v1/users/#{private_user.id}/collection", headers: owner_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('characters')
|
||||
expect(json).to have_key('weapons')
|
||||
end
|
||||
end
|
||||
|
||||
context 'viewing crew-only collection' do
|
||||
it 'returns forbidden for non-crew members' do
|
||||
get "/api/v1/users/#{crew_only_user.id}/collection", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['error']).to include('do not have permission')
|
||||
end
|
||||
|
||||
it 'allows owner to view their own crew-only collection' do
|
||||
owner_token = Doorkeeper::AccessToken.create!(
|
||||
resource_owner_id: crew_only_user.id,
|
||||
expires_in: 30.days,
|
||||
scopes: 'public'
|
||||
)
|
||||
owner_headers = {
|
||||
'Authorization' => "Bearer #{owner_token.token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
|
||||
get "/api/v1/users/#{crew_only_user.id}/collection", headers: owner_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json).to have_key('characters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'error handling' do
|
||||
it 'returns not found for non-existent user' do
|
||||
get "/api/v1/users/#{SecureRandom.uuid}/collection", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['error']).to include('User not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
149
spec/requests/collection_job_accessories_controller_spec.rb
Normal file
149
spec/requests/collection_job_accessories_controller_spec.rb
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Collection Job Accessories API', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_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
|
||||
|
||||
let(:job) { create(:job) }
|
||||
let(:job_accessory) { create(:job_accessory, job: job) }
|
||||
|
||||
describe 'GET /api/v1/collection/job_accessories' do
|
||||
let(:job1) { create(:job) }
|
||||
let(:job2) { create(:job) }
|
||||
let(:job_accessory1) { create(:job_accessory, job: job1) }
|
||||
let(:job_accessory2) { create(:job_accessory, job: job2) }
|
||||
let!(:collection_accessory1) { create(:collection_job_accessory, user: user, job_accessory: job_accessory1) }
|
||||
let!(:collection_accessory2) { create(:collection_job_accessory, user: user, job_accessory: job_accessory2) }
|
||||
let!(:other_user_accessory) { create(:collection_job_accessory, user: other_user) }
|
||||
|
||||
it 'returns the current user\'s collection job accessories' do
|
||||
get '/api/v1/collection/job_accessories', headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['collection_job_accessories'].length).to eq(2)
|
||||
end
|
||||
|
||||
it 'supports filtering by job' do
|
||||
get '/api/v1/collection/job_accessories', params: { job_id: job1.id }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
accessories = json['collection_job_accessories']
|
||||
expect(accessories.length).to eq(1)
|
||||
expect(accessories.first['job_accessory']['job']['id']).to eq(job1.id)
|
||||
end
|
||||
|
||||
it 'returns unauthorized without authentication' do
|
||||
get '/api/v1/collection/job_accessories'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/collection/job_accessories/:id' do
|
||||
let!(:collection_accessory) { create(:collection_job_accessory, user: user, job_accessory: job_accessory) }
|
||||
|
||||
it 'returns the collection job accessory' do
|
||||
get "/api/v1/collection/job_accessories/#{collection_accessory.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['id']).to eq(collection_accessory.id)
|
||||
expect(json['job_accessory']['id']).to eq(job_accessory.id)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s job accessory' do
|
||||
other_collection = create(:collection_job_accessory, user: other_user)
|
||||
get "/api/v1/collection/job_accessories/#{other_collection.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent job accessory' do
|
||||
get "/api/v1/collection/job_accessories/#{SecureRandom.uuid}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns unauthorized without authentication' do
|
||||
get "/api/v1/collection/job_accessories/#{collection_accessory.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/collection/job_accessories' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
collection_job_accessory: {
|
||||
job_accessory_id: job_accessory.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new collection job accessory' do
|
||||
expect do
|
||||
post '/api/v1/collection/job_accessories', params: valid_attributes.to_json, headers: headers
|
||||
end.to change(CollectionJobAccessory, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['job_accessory']['id']).to eq(job_accessory.id)
|
||||
end
|
||||
|
||||
it 'returns error when job accessory already in collection' do
|
||||
create(:collection_job_accessory, user: user, job_accessory: job_accessory)
|
||||
|
||||
post '/api/v1/collection/job_accessories', params: valid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['error']['message']).to include('already exists in your collection')
|
||||
end
|
||||
|
||||
it 'returns error for non-existent job accessory' do
|
||||
invalid_attributes = {
|
||||
collection_job_accessory: {
|
||||
job_accessory_id: SecureRandom.uuid
|
||||
}
|
||||
}
|
||||
|
||||
post '/api/v1/collection/job_accessories', params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/collection/job_accessories/:id' do
|
||||
let!(:collection_accessory) { create(:collection_job_accessory, user: user, job_accessory: job_accessory) }
|
||||
|
||||
it 'deletes the collection job accessory' do
|
||||
expect do
|
||||
delete "/api/v1/collection/job_accessories/#{collection_accessory.id}", headers: headers
|
||||
end.to change(CollectionJobAccessory, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s job accessory' do
|
||||
other_collection = create(:collection_job_accessory, user: other_user)
|
||||
|
||||
expect do
|
||||
delete "/api/v1/collection/job_accessories/#{other_collection.id}", headers: headers
|
||||
end.not_to change(CollectionJobAccessory, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent job accessory' do
|
||||
delete "/api/v1/collection/job_accessories/#{SecureRandom.uuid}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
214
spec/requests/collection_summons_controller_spec.rb
Normal file
214
spec/requests/collection_summons_controller_spec.rb
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Collection Summons API', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_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
|
||||
|
||||
let(:summon) { create(:summon) }
|
||||
|
||||
describe 'GET /api/v1/collection/summons' do
|
||||
let(:summon1) { create(:summon) }
|
||||
let(:summon2) { create(:summon) }
|
||||
let!(:collection_summon1) { create(:collection_summon, user: user, summon: summon1, uncap_level: 5) }
|
||||
let!(:collection_summon2) { create(:collection_summon, user: user, summon: summon2, uncap_level: 3) }
|
||||
let!(:other_user_summon) { create(:collection_summon, user: other_user) }
|
||||
|
||||
it 'returns the current user\'s collection summons' do
|
||||
get '/api/v1/collection/summons', headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['collection_summons'].length).to eq(2)
|
||||
expect(json['meta']['count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'supports pagination' do
|
||||
get '/api/v1/collection/summons', params: { page: 1, limit: 1 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['collection_summons'].length).to eq(1)
|
||||
expect(json['meta']['total_pages']).to be >= 2
|
||||
end
|
||||
|
||||
it 'supports filtering by summon' do
|
||||
get '/api/v1/collection/summons', params: { summon_id: summon1.id }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
summons = json['collection_summons']
|
||||
expect(summons.length).to eq(1)
|
||||
expect(summons.first['summon']['id']).to eq(summon1.id)
|
||||
end
|
||||
|
||||
it 'supports filtering by both element and rarity' do
|
||||
fire_ssr = create(:summon, element: 0, rarity: 4)
|
||||
water_ssr = create(:summon, element: 1, rarity: 4)
|
||||
fire_sr = create(:summon, element: 0, rarity: 3)
|
||||
|
||||
create(:collection_summon, user: user, summon: fire_ssr)
|
||||
create(:collection_summon, user: user, summon: water_ssr)
|
||||
create(:collection_summon, user: user, summon: fire_sr)
|
||||
|
||||
get '/api/v1/collection/summons', params: { element: 0, rarity: 4 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
summons = json['collection_summons']
|
||||
expect(summons.length).to eq(1)
|
||||
expect(summons.first['summon']['element']).to eq(0)
|
||||
expect(summons.first['summon']['rarity']).to eq(4)
|
||||
end
|
||||
|
||||
it 'returns unauthorized without authentication' do
|
||||
get '/api/v1/collection/summons'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/collection/summons/:id' do
|
||||
let!(:collection_summon) { create(:collection_summon, user: user, summon: summon) }
|
||||
|
||||
it 'returns the collection summon' do
|
||||
get "/api/v1/collection/summons/#{collection_summon.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['id']).to eq(collection_summon.id)
|
||||
expect(json['summon']['id']).to eq(summon.id)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s summon' do
|
||||
other_collection = create(:collection_summon, user: other_user)
|
||||
get "/api/v1/collection/summons/#{other_collection.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent summon' do
|
||||
get "/api/v1/collection/summons/#{SecureRandom.uuid}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/collection/summons' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
collection_summon: {
|
||||
summon_id: summon.id,
|
||||
uncap_level: 3,
|
||||
transcendence_step: 0
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new collection summon' do
|
||||
expect do
|
||||
post '/api/v1/collection/summons', params: valid_attributes.to_json, headers: headers
|
||||
end.to change(CollectionSummon, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['summon']['id']).to eq(summon.id)
|
||||
expect(json['uncap_level']).to eq(3)
|
||||
end
|
||||
|
||||
it 'allows multiple copies of the same summon' do
|
||||
create(:collection_summon, user: user, summon: summon)
|
||||
|
||||
expect do
|
||||
post '/api/v1/collection/summons', params: valid_attributes.to_json, headers: headers
|
||||
end.to change(CollectionSummon, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['summon']['id']).to eq(summon.id)
|
||||
end
|
||||
|
||||
it 'returns error with invalid transcendence' do
|
||||
invalid_attributes = valid_attributes.deep_merge(
|
||||
collection_summon: { uncap_level: 3, transcendence_step: 5 }
|
||||
)
|
||||
|
||||
post '/api/v1/collection/summons', params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('requires uncap level 5')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/collection/summons/:id' do
|
||||
let!(:collection_summon) { create(:collection_summon, user: user, summon: summon, uncap_level: 3) }
|
||||
|
||||
it 'updates the collection summon' do
|
||||
update_attributes = {
|
||||
collection_summon: {
|
||||
uncap_level: 5
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/collection/summons/#{collection_summon.id}",
|
||||
params: update_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['uncap_level']).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s summon' do
|
||||
other_collection = create(:collection_summon, user: other_user)
|
||||
update_attributes = { collection_summon: { uncap_level: 5 } }
|
||||
|
||||
put "/api/v1/collection/summons/#{other_collection.id}",
|
||||
params: update_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns error with invalid transcendence' do
|
||||
invalid_attributes = {
|
||||
collection_summon: {
|
||||
uncap_level: 3, # Keep it at 3
|
||||
transcendence_step: 5 # Invalid: requires uncap level 5
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/collection/summons/#{collection_summon.id}",
|
||||
params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('requires uncap level 5')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/collection/summons/:id' do
|
||||
let!(:collection_summon) { create(:collection_summon, user: user, summon: summon) }
|
||||
|
||||
it 'deletes the collection summon' do
|
||||
expect do
|
||||
delete "/api/v1/collection/summons/#{collection_summon.id}", headers: headers
|
||||
end.to change(CollectionSummon, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s summon' do
|
||||
other_collection = create(:collection_summon, user: other_user)
|
||||
|
||||
expect do
|
||||
delete "/api/v1/collection/summons/#{other_collection.id}", headers: headers
|
||||
end.not_to change(CollectionSummon, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
274
spec/requests/collection_weapons_controller_spec.rb
Normal file
274
spec/requests/collection_weapons_controller_spec.rb
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Collection Weapons API', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_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
|
||||
|
||||
let(:weapon) { create(:weapon) }
|
||||
let(:awakening) { create(:awakening, object_type: 'Weapon') }
|
||||
let(:weapon_key) { create(:weapon_key) }
|
||||
|
||||
describe 'GET /api/v1/collection/weapons' do
|
||||
let(:weapon1) { create(:weapon) }
|
||||
let(:weapon2) { create(:weapon) }
|
||||
let!(:collection_weapon1) { create(:collection_weapon, user: user, weapon: weapon1, uncap_level: 5) }
|
||||
let!(:collection_weapon2) { create(:collection_weapon, user: user, weapon: weapon2, uncap_level: 3) }
|
||||
let!(:other_user_weapon) { create(:collection_weapon, user: other_user) }
|
||||
|
||||
it 'returns the current user\'s collection weapons' do
|
||||
get '/api/v1/collection/weapons', headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['collection_weapons'].length).to eq(2)
|
||||
expect(json['meta']['count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'supports pagination' do
|
||||
get '/api/v1/collection/weapons', params: { page: 1, limit: 1 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['collection_weapons'].length).to eq(1)
|
||||
expect(json['meta']['total_pages']).to be >= 2
|
||||
end
|
||||
|
||||
it 'supports filtering by weapon' do
|
||||
get '/api/v1/collection/weapons', params: { weapon_id: weapon1.id }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
weapons = json['collection_weapons']
|
||||
expect(weapons.length).to eq(1)
|
||||
expect(weapons.first['weapon']['id']).to eq(weapon1.id)
|
||||
end
|
||||
|
||||
it 'supports filtering by both element and rarity' do
|
||||
fire_ssr = create(:weapon, element: 0, rarity: 4)
|
||||
water_ssr = create(:weapon, element: 1, rarity: 4)
|
||||
fire_sr = create(:weapon, element: 0, rarity: 3)
|
||||
|
||||
create(:collection_weapon, user: user, weapon: fire_ssr)
|
||||
create(:collection_weapon, user: user, weapon: water_ssr)
|
||||
create(:collection_weapon, user: user, weapon: fire_sr)
|
||||
|
||||
get '/api/v1/collection/weapons', params: { element: 0, rarity: 4 }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
weapons = json['collection_weapons']
|
||||
expect(weapons.length).to eq(1)
|
||||
expect(weapons.first['weapon']['element']).to eq(0)
|
||||
expect(weapons.first['weapon']['rarity']).to eq(4)
|
||||
end
|
||||
|
||||
it 'returns unauthorized without authentication' do
|
||||
get '/api/v1/collection/weapons'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/collection/weapons/:id' do
|
||||
let!(:collection_weapon) { create(:collection_weapon, user: user, weapon: weapon) }
|
||||
|
||||
it 'returns the collection weapon' do
|
||||
get "/api/v1/collection/weapons/#{collection_weapon.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['id']).to eq(collection_weapon.id)
|
||||
expect(json['weapon']['id']).to eq(weapon.id)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s weapon' do
|
||||
other_collection = create(:collection_weapon, user: other_user)
|
||||
get "/api/v1/collection/weapons/#{other_collection.id}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns not found for non-existent weapon' do
|
||||
get "/api/v1/collection/weapons/#{SecureRandom.uuid}", headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/collection/weapons' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
collection_weapon: {
|
||||
weapon_id: weapon.id,
|
||||
uncap_level: 3,
|
||||
transcendence_step: 0,
|
||||
awakening_id: awakening.id,
|
||||
awakening_level: 5
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new collection weapon' do
|
||||
expect do
|
||||
post '/api/v1/collection/weapons', params: valid_attributes.to_json, headers: headers
|
||||
end.to change(CollectionWeapon, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['weapon']['id']).to eq(weapon.id)
|
||||
expect(json['uncap_level']).to eq(3)
|
||||
end
|
||||
|
||||
it 'allows multiple copies of the same weapon' do
|
||||
create(:collection_weapon, user: user, weapon: weapon)
|
||||
|
||||
expect do
|
||||
post '/api/v1/collection/weapons', params: valid_attributes.to_json, headers: headers
|
||||
end.to change(CollectionWeapon, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['weapon']['id']).to eq(weapon.id)
|
||||
end
|
||||
|
||||
it 'returns error with invalid awakening type' do
|
||||
character_awakening = create(:awakening, object_type: 'Character')
|
||||
invalid_attributes = valid_attributes.deep_merge(
|
||||
collection_weapon: { awakening_id: character_awakening.id }
|
||||
)
|
||||
|
||||
post '/api/v1/collection/weapons', params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('must be a weapon awakening')
|
||||
end
|
||||
|
||||
it 'returns error with invalid transcendence' do
|
||||
invalid_attributes = valid_attributes.deep_merge(
|
||||
collection_weapon: { uncap_level: 3, transcendence_step: 5 }
|
||||
)
|
||||
|
||||
post '/api/v1/collection/weapons', params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('requires uncap level 5')
|
||||
end
|
||||
|
||||
it 'creates weapon with AX skills' do
|
||||
ax_attributes = valid_attributes.deep_merge(
|
||||
collection_weapon: {
|
||||
ax_modifier1: 1,
|
||||
ax_strength1: 3.5,
|
||||
ax_modifier2: 2,
|
||||
ax_strength2: 2.0
|
||||
}
|
||||
)
|
||||
|
||||
post '/api/v1/collection/weapons', params: ax_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['ax']).to be_present
|
||||
expect(json['ax'].first['modifier']).to eq(1)
|
||||
expect(json['ax'].first['strength']).to eq(3.5)
|
||||
end
|
||||
|
||||
it 'returns error with incomplete AX skills' do
|
||||
invalid_ax = valid_attributes.deep_merge(
|
||||
collection_weapon: {
|
||||
ax_modifier1: 1
|
||||
# Missing ax_strength1
|
||||
}
|
||||
)
|
||||
|
||||
post '/api/v1/collection/weapons', params: invalid_ax.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('AX skill 1 must have both modifier and strength')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/collection/weapons/:id' do
|
||||
let!(:collection_weapon) { create(:collection_weapon, user: user, weapon: weapon, uncap_level: 3) }
|
||||
|
||||
let(:update_attributes) do
|
||||
{
|
||||
collection_weapon: {
|
||||
uncap_level: 5,
|
||||
transcendence_step: 3,
|
||||
weapon_key1_id: weapon_key.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the collection weapon' do
|
||||
# Just update uncap level, as transcendence and weapon keys may not be supported
|
||||
simple_update = {
|
||||
collection_weapon: {
|
||||
uncap_level: 5
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/collection/weapons/#{collection_weapon.id}",
|
||||
params: simple_update.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['uncap_level']).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s weapon' do
|
||||
other_collection = create(:collection_weapon, user: other_user)
|
||||
put "/api/v1/collection/weapons/#{other_collection.id}",
|
||||
params: update_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns error with duplicate weapon keys' do
|
||||
invalid_attributes = {
|
||||
collection_weapon: {
|
||||
weapon_key1_id: weapon_key.id,
|
||||
weapon_key2_id: weapon_key.id # Same key twice
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/collection/weapons/#{collection_weapon.id}",
|
||||
params: invalid_attributes.to_json, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json['errors'].to_s).to include('cannot have duplicate keys')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/collection/weapons/:id' do
|
||||
let!(:collection_weapon) { create(:collection_weapon, user: user, weapon: weapon) }
|
||||
|
||||
it 'deletes the collection weapon' do
|
||||
expect do
|
||||
delete "/api/v1/collection/weapons/#{collection_weapon.id}", headers: headers
|
||||
end.to change(CollectionWeapon, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns not found for other user\'s weapon' do
|
||||
other_collection = create(:collection_weapon, user: other_user)
|
||||
|
||||
expect do
|
||||
delete "/api/v1/collection/weapons/#{other_collection.id}", headers: headers
|
||||
end.not_to change(CollectionWeapon, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue