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
|
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
|
end
|
||||||
|
|
||||||
view :preview do
|
view :preview do
|
||||||
association :character, name: :object, blueprint: CharacterBlueprint
|
association :character, blueprint: CharacterBlueprint
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested do
|
view :nested do
|
||||||
include_view :mastery_bonuses
|
include_view :mastery_bonuses
|
||||||
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
|
association :character, blueprint: CharacterBlueprint, view: :full
|
||||||
end
|
end
|
||||||
|
|
||||||
view :uncap do
|
view :uncap do
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ module Api
|
||||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||||
|
|
||||||
view :preview do
|
view :preview do
|
||||||
association :summon, name: :object, blueprint: SummonBlueprint
|
association :summon, blueprint: SummonBlueprint
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested do
|
view :nested do
|
||||||
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
|
association :summon, blueprint: SummonBlueprint, view: :full
|
||||||
end
|
end
|
||||||
|
|
||||||
view :full do
|
view :full do
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ module Api
|
||||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||||
|
|
||||||
view :preview do
|
view :preview do
|
||||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
association :weapon, blueprint: WeaponBlueprint
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested do
|
view :nested do
|
||||||
|
|
@ -24,7 +24,7 @@ module Api
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
|
association :weapon, blueprint: WeaponBlueprint, view: :full,
|
||||||
if: ->(_field_name, w, _options) { w.weapon.present? }
|
if: ->(_field_name, w, _options) { w.weapon.present? }
|
||||||
|
|
||||||
association :weapon_keys,
|
association :weapon_keys,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ module Api
|
||||||
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
|
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
|
||||||
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_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|
|
rescue_from GranblueError do |e|
|
||||||
render_error(e)
|
render_error(e)
|
||||||
end
|
end
|
||||||
|
|
@ -157,6 +162,17 @@ module Api
|
||||||
ensure
|
ensure
|
||||||
Prosopite.finish
|
Prosopite.finish
|
||||||
end
|
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
|
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
|
existing.destroy
|
||||||
end
|
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!(
|
grid_character = GridCharacter.create!(
|
||||||
party_id: @party.id,
|
party_id: @party.id,
|
||||||
character_id: incoming.id,
|
character_id: incoming.id,
|
||||||
position: resolve_params[:position],
|
position: resolve_params[:position],
|
||||||
uncap_level: uncap_level
|
uncap_level: compute_max_uncap_level(incoming)
|
||||||
)
|
)
|
||||||
render json: GridCharacterBlueprint.render(grid_character,
|
render json: GridCharacterBlueprint.render(grid_character,
|
||||||
root: :grid_character,
|
root: :grid_character,
|
||||||
|
|
@ -248,7 +237,8 @@ module Api
|
||||||
grid_character = GridCharacter.new(
|
grid_character = GridCharacter.new(
|
||||||
character_params.except(:rings, :awakening).merge(
|
character_params.except(:rings, :awakening).merge(
|
||||||
party_id: @party.id,
|
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)
|
assign_transformed_attributes(grid_character, processed_params)
|
||||||
|
|
@ -256,6 +246,24 @@ module Api
|
||||||
grid_character
|
grid_character
|
||||||
end
|
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.
|
# Assigns raw attributes from the original parameters to the grid character.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,9 @@ module Api
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def create
|
def create
|
||||||
# Build a new grid summon using permitted parameters merged with party and summon IDs.
|
# 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
|
# Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
|
||||||
# if it hasn't already been provided.
|
|
||||||
grid_summon = build_grid_summon.tap do |gs|
|
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
|
end
|
||||||
|
|
||||||
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
# 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(
|
grid_weapon = GridWeapon.new(
|
||||||
weapon_params.merge(
|
weapon_params.merge(
|
||||||
party_id: @party.id,
|
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
|
end
|
||||||
|
|
||||||
def update_job_skills
|
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
|
# Determine which incoming keys contain new skills
|
||||||
skill_keys = %w[skill1_id skill2_id skill3_id]
|
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
|
# If there are new skills, merge them with the existing skills
|
||||||
unless new_skill_keys.empty?
|
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 = {
|
existing_skills = {
|
||||||
1 => @party.skill1,
|
1 => @party.skill1,
|
||||||
2 => @party.skill2,
|
2 => @party.skill2,
|
||||||
3 => @party.skill3
|
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)
|
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|
|
skill_ids_hash = merged.each_with_object({}) do |(index, skill), memo|
|
||||||
memo["skill#{index}_id"] = skill.id if skill
|
memo["skill#{index}_id"] = skill&.id
|
||||||
end
|
end
|
||||||
|
|
||||||
@party.attributes = new_skill_ids
|
@party.attributes = skill_ids_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
|
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy_job_skill
|
def destroy_job_skill
|
||||||
position = job_params[:skill_position].to_i
|
position = job_params[:skill_position].to_i
|
||||||
@party["skill#{position}_id"] = nil
|
@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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def merge_skills_with_existing_skills(
|
def merge_skills_with_loaded_skills(existing_skills, new_skills, positions)
|
||||||
existing_skills,
|
# new_skills is now an array of already-loaded JobSkill objects
|
||||||
new_skill_ids,
|
|
||||||
positions
|
|
||||||
)
|
|
||||||
new_skills = new_skill_ids.map { |id| JobSkill.find(id) }
|
|
||||||
|
|
||||||
new_skills.each_with_index do |skill, index|
|
new_skills.each_with_index do |skill, index|
|
||||||
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
|
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
|
||||||
end
|
end
|
||||||
|
|
@ -182,7 +182,8 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
def set
|
def set
|
||||||
@party = Party.where('id = ?', params[:id]).first
|
@party = Party.find_by(shortcode: params[:id])
|
||||||
|
render_not_found_response('party') unless @party
|
||||||
end
|
end
|
||||||
|
|
||||||
def job_params
|
def job_params
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,14 @@ module Api
|
||||||
def index
|
def index
|
||||||
query = build_filtered_query(build_common_base_query)
|
query = build_filtered_query(build_common_base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: page_size)
|
@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
|
end
|
||||||
|
|
||||||
# GET /api/v1/parties/favorites
|
# GET /api/v1/parties/favorites
|
||||||
|
|
@ -159,7 +166,14 @@ module Api
|
||||||
.distinct
|
.distinct
|
||||||
query = build_filtered_query(base_query)
|
query = build_filtered_query(base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: page_size)
|
@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
|
end
|
||||||
|
|
||||||
# Preview Management
|
# Preview Management
|
||||||
|
|
|
||||||
|
|
@ -87,11 +87,7 @@ module Api
|
||||||
render json: CharacterBlueprint.render(paginated,
|
render json: CharacterBlueprint.render(paginated,
|
||||||
view: :dates,
|
view: :dates,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: search_page_size
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def weapons
|
def weapons
|
||||||
|
|
@ -126,11 +122,7 @@ module Api
|
||||||
render json: WeaponBlueprint.render(paginated,
|
render json: WeaponBlueprint.render(paginated,
|
||||||
view: :dates,
|
view: :dates,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: search_page_size
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def summons
|
def summons
|
||||||
|
|
@ -160,11 +152,7 @@ module Api
|
||||||
render json: SummonBlueprint.render(paginated,
|
render json: SummonBlueprint.render(paginated,
|
||||||
view: :dates,
|
view: :dates,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: search_page_size
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def job_skills
|
def job_skills
|
||||||
|
|
@ -248,11 +236,7 @@ module Api
|
||||||
|
|
||||||
render json: JobSkillBlueprint.render(paginated,
|
render json: JobSkillBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: search_page_size
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def guidebooks
|
def guidebooks
|
||||||
|
|
@ -268,20 +252,11 @@ module Api
|
||||||
|
|
||||||
render json: GuidebookBlueprint.render(paginated,
|
render json: GuidebookBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: search_page_size
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def total_pages(count)
|
|
||||||
per_page = search_page_size
|
|
||||||
(count.to_f / per_page).ceil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Specify whitelisted properties that can be modified.
|
# Specify whitelisted properties that can be modified.
|
||||||
def search_params
|
def search_params
|
||||||
return {} unless params[:search].present?
|
return {} unless params[:search].present?
|
||||||
|
|
|
||||||
|
|
@ -31,21 +31,6 @@ module PartyQueryingConcern
|
||||||
options: { apply_defaults: true }).build
|
options: { apply_defaults: true }).build
|
||||||
end
|
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.
|
# Returns a remixed party name based on the current party name and current_user language.
|
||||||
def remixed_name(name)
|
def remixed_name(name)
|
||||||
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : 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
|
##### ActiveRecord Associations
|
||||||
has_many :parties, dependent: :destroy
|
has_many :parties, dependent: :destroy
|
||||||
has_many :favorites, 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
|
##### ActiveRecord Validations
|
||||||
validates :username,
|
validates :username,
|
||||||
|
|
@ -39,6 +46,15 @@ class User < ApplicationRecord
|
||||||
##### ActiveModel Security
|
##### ActiveModel Security
|
||||||
has_secure_password
|
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
|
def favorite_parties
|
||||||
favorites.map(&:party)
|
favorites.map(&:party)
|
||||||
end
|
end
|
||||||
|
|
@ -50,4 +66,30 @@ class User < ApplicationRecord
|
||||||
def blueprint
|
def blueprint
|
||||||
UserBlueprint
|
UserBlueprint
|
||||||
end
|
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
|
class ConfigurationError < StandardError; end
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
Rails.logger.info "Environment: #{Rails.env}"
|
Rails.logger.debug "Environment: #{Rails.env}"
|
||||||
|
|
||||||
# Try different methods of getting credentials
|
# Try different methods of getting credentials
|
||||||
creds = get_credentials
|
creds = get_credentials
|
||||||
Rails.logger.info "Credentials source: #{creds[:source]}"
|
Rails.logger.debug "Credentials source: #{creds[:source]}"
|
||||||
|
|
||||||
@s3_client = Aws::S3::Client.new(
|
@s3_client = Aws::S3::Client.new(
|
||||||
region: creds[:region],
|
region: creds[:region],
|
||||||
|
|
@ -44,14 +44,14 @@ class AwsService
|
||||||
# Try Rails credentials first
|
# Try Rails credentials first
|
||||||
rails_creds = Rails.application.credentials.dig(:aws)
|
rails_creds = Rails.application.credentials.dig(:aws)
|
||||||
if rails_creds&.dig(:access_key_id)
|
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')
|
return rails_creds.merge(source: 'rails_credentials')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Try string keys
|
# Try string keys
|
||||||
rails_creds = Rails.application.credentials.dig('aws')
|
rails_creds = Rails.application.credentials.dig('aws')
|
||||||
if rails_creds&.dig('access_key_id')
|
if rails_creds&.dig('access_key_id')
|
||||||
Rails.logger.info "Using Rails credentials (string keys)"
|
Rails.logger.debug "Using Rails credentials (string keys)"
|
||||||
return {
|
return {
|
||||||
region: rails_creds['region'],
|
region: rails_creds['region'],
|
||||||
access_key_id: rails_creds['access_key_id'],
|
access_key_id: rails_creds['access_key_id'],
|
||||||
|
|
@ -63,7 +63,7 @@ class AwsService
|
||||||
|
|
||||||
# Try environment variables
|
# Try environment variables
|
||||||
if ENV['AWS_ACCESS_KEY_ID']
|
if ENV['AWS_ACCESS_KEY_ID']
|
||||||
Rails.logger.info "Using environment variables"
|
Rails.logger.debug "Using environment variables"
|
||||||
return {
|
return {
|
||||||
region: ENV['AWS_REGION'],
|
region: ENV['AWS_REGION'],
|
||||||
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
|
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
|
||||||
|
|
@ -75,7 +75,7 @@ class AwsService
|
||||||
|
|
||||||
# Try alternate environment variable names
|
# Try alternate environment variable names
|
||||||
if ENV['RAILS_AWS_ACCESS_KEY_ID']
|
if ENV['RAILS_AWS_ACCESS_KEY_ID']
|
||||||
Rails.logger.info "Using Rails-prefixed environment variables"
|
Rails.logger.debug "Using Rails-prefixed environment variables"
|
||||||
return {
|
return {
|
||||||
region: ENV['RAILS_AWS_REGION'],
|
region: ENV['RAILS_AWS_REGION'],
|
||||||
access_key_id: ENV['RAILS_AWS_ACCESS_KEY_ID'],
|
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'
|
post 'parties/:id/grid_update', to: 'parties#grid_update'
|
||||||
|
|
||||||
delete 'favorites', to: 'favorites#destroy'
|
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
|
end
|
||||||
|
|
||||||
if Rails.env.development?
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "btree_gin"
|
enable_extension "btree_gin"
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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"
|
t.index ["skill_id"], name: "index_charge_attacks_on_skill_id"
|
||||||
end
|
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|
|
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -569,6 +640,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
|
||||||
t.integer "gender", default: 0, null: false
|
t.integer "gender", default: 0, null: false
|
||||||
t.string "theme", default: "system", null: false
|
t.string "theme", default: "system", null: false
|
||||||
t.integer "role", default: 1, 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
|
end
|
||||||
|
|
||||||
create_table "weapon_awakenings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
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 "character_skills", "skills", column: "alt_skill_id"
|
||||||
add_foreign_key "charge_attacks", "skills"
|
add_foreign_key "charge_attacks", "skills"
|
||||||
add_foreign_key "charge_attacks", "skills", column: "alt_skill_id"
|
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 "effects", "effects", column: "effect_family_id"
|
||||||
add_foreign_key "favorites", "parties"
|
add_foreign_key "favorites", "parties"
|
||||||
add_foreign_key "favorites", "users"
|
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).
|
# Remove the :id attribute unless we want to preserve it (for fixed canonical IDs).
|
||||||
attrs.except!(:id) unless use_id
|
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.
|
# Find or create the record based on the unique key.
|
||||||
record = model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
|
record = model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
|
||||||
# Assign all attributes except the unique_key.
|
# Assign all attributes except the unique_key.
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ def seed_weapons
|
||||||
w.max_atk = row['max_hp']
|
w.max_atk = row['max_hp']
|
||||||
w.max_atk_flb = row['max_hp_flb']
|
w.max_atk_flb = row['max_hp_flb']
|
||||||
w.max_atk_ulb = row['max_hp_ulb']
|
w.max_atk_ulb = row['max_hp_ulb']
|
||||||
w.recruits_id = row['recruits_id']
|
w.recruits = row['recruits']
|
||||||
w.save
|
w.save
|
||||||
end
|
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
|
# @return [Array<String>] Available image size variants
|
||||||
SIZES = %w[main grid square].freeze
|
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
|
# Initialize a new downloader instance
|
||||||
# @param id [String] ID of the object to download images for
|
# @param id [String] ID of the object to download images for
|
||||||
# @param test_mode [Boolean] When true, only logs actions without downloading
|
# @param test_mode [Boolean] When true, only logs actions without downloading
|
||||||
|
|
@ -42,7 +61,7 @@ module Granblue
|
||||||
@verbose = verbose
|
@verbose = verbose
|
||||||
@storage = storage
|
@storage = storage
|
||||||
@logger = logger || Logger.new($stdout) # fallback logger
|
@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
|
ensure_directories_exist unless @test_mode
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -158,7 +177,8 @@ module Granblue
|
||||||
def ensure_directories_exist
|
def ensure_directories_exist
|
||||||
return unless store_locally?
|
return unless store_locally?
|
||||||
|
|
||||||
SIZES.each do |size|
|
sizes = self.class::SIZES rescue SIZES
|
||||||
|
sizes.each do |size|
|
||||||
FileUtils.mkdir_p(download_path(size))
|
FileUtils.mkdir_p(download_path(size))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -169,6 +189,12 @@ module Granblue
|
||||||
%i[local both].include?(@storage)
|
%i[local both].include?(@storage)
|
||||||
end
|
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
|
# Get local download path for a size
|
||||||
# @param size [String] Image size variant
|
# @param size [String] Image size variant
|
||||||
# @return [String] Local directory path
|
# @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 Character images come in multiple variants (_01, _02, etc.) based on uncap status
|
||||||
# @note Supports FLB (5★) and ULB (6★) art variants when available
|
# @note Supports FLB (5★) and ULB (6★) art variants when available
|
||||||
class CharacterDownloader < BaseDownloader
|
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.
|
# Downloads images for all variants of a character based on their uncap status.
|
||||||
# Overrides {BaseDownloader#download} to handle character-specific variants.
|
# Overrides {BaseDownloader#download} to handle character-specific variants.
|
||||||
#
|
#
|
||||||
|
|
@ -66,7 +68,7 @@ module Granblue
|
||||||
sizes.each_with_index do |size, index|
|
sizes.each_with_index do |size, index|
|
||||||
path = download_path(size)
|
path = download_path(size)
|
||||||
url = build_variant_url(variant_id, 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -94,7 +96,7 @@ module Granblue
|
||||||
# Gets base URL for character assets
|
# Gets base URL for character assets
|
||||||
# @return [String] Base URL for character images
|
# @return [String] Base URL for character images
|
||||||
def base_url
|
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
|
end
|
||||||
|
|
||||||
# Gets directory name for a size variant
|
# 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 Summon images come in multiple variants based on uncap status
|
||||||
# @note Supports ULB (5★) and transcendence variants when available
|
# @note Supports ULB (5★) and transcendence variants when available
|
||||||
class SummonDownloader < BaseDownloader
|
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.
|
# Downloads images for all variants of a summon based on their uncap status.
|
||||||
# Overrides {BaseDownloader#download} to handle summon-specific variants.
|
# Overrides {BaseDownloader#download} to handle summon-specific variants.
|
||||||
#
|
#
|
||||||
|
|
@ -68,7 +70,7 @@ module Granblue
|
||||||
sizes.each_with_index do |size, index|
|
sizes.each_with_index do |size, index|
|
||||||
path = download_path(size)
|
path = download_path(size)
|
||||||
url = build_variant_url(variant_id, 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -95,18 +97,19 @@ module Granblue
|
||||||
# Gets base URL for summon assets
|
# Gets base URL for summon assets
|
||||||
# @return [String] Base URL for summon images
|
# @return [String] Base URL for summon images
|
||||||
def base_url
|
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
|
end
|
||||||
|
|
||||||
# Gets directory name for a size variant
|
# Gets directory name for a size variant
|
||||||
#
|
#
|
||||||
# @param size [String] Image size variant
|
# @param size [String] Image size variant
|
||||||
# @return [String] Directory name in game asset URL structure
|
# @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)
|
def directory_for_size(size)
|
||||||
case size.to_s
|
case size.to_s
|
||||||
when 'main' then 'ls'
|
when 'main' then 'ls'
|
||||||
when 'grid' then 'm'
|
when 'grid' then 'party_sub'
|
||||||
|
when 'wide' then 'm'
|
||||||
when 'square' then 's'
|
when 'square' then 's'
|
||||||
when 'detail' then 'detail'
|
when 'detail' then 'detail'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ module Granblue
|
||||||
# @note Supports transcendence variants and element-specific variants
|
# @note Supports transcendence variants and element-specific variants
|
||||||
# @see ElementalWeaponDownloader for handling multi-element weapons
|
# @see ElementalWeaponDownloader for handling multi-element weapons
|
||||||
class WeaponDownloader < BaseDownloader
|
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.
|
# Downloads images for all variants of a weapon based on their uncap status.
|
||||||
# Overrides {BaseDownloader#download} to handle weapon-specific variants.
|
# Overrides {BaseDownloader#download} to handle weapon-specific variants.
|
||||||
#
|
#
|
||||||
|
|
@ -66,18 +68,18 @@ module Granblue
|
||||||
sizes.each_with_index do |size, index|
|
sizes.each_with_index do |size, index|
|
||||||
path = download_path(size)
|
path = download_path(size)
|
||||||
url = build_variant_url(variant_id, 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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Builds URL for a specific variant and size
|
# Builds URL for a specific variant and size
|
||||||
#
|
#
|
||||||
# @param variant_id [String] Weapon variant ID
|
# @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
|
# @return [String] Complete URL for downloading the image
|
||||||
def build_variant_url(variant_id, size)
|
def build_variant_url(variant_id, size)
|
||||||
directory = directory_for_size(size)
|
directory = directory_for_size(size)
|
||||||
if size == 'raw'
|
if size == 'base'
|
||||||
"#{@base_url}/#{directory}/#{variant_id}.png"
|
"#{@base_url}/#{directory}/#{variant_id}.png"
|
||||||
else
|
else
|
||||||
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
"#{@base_url}/#{directory}/#{variant_id}.jpg"
|
||||||
|
|
@ -93,20 +95,20 @@ module Granblue
|
||||||
# Gets base URL for weapon assets
|
# Gets base URL for weapon assets
|
||||||
# @return [String] Base URL for weapon images
|
# @return [String] Base URL for weapon images
|
||||||
def base_url
|
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
|
end
|
||||||
|
|
||||||
# Gets directory name for a size variant
|
# Gets directory name for a size variant
|
||||||
#
|
#
|
||||||
# @param size [String] Image size variant
|
# @param size [String] Image size variant
|
||||||
# @return [String] Directory name in game asset URL structure
|
# @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)
|
def directory_for_size(size)
|
||||||
case size.to_s
|
case size.to_s
|
||||||
when 'main' then 'ls'
|
when 'main' then 'ls'
|
||||||
when 'grid' then 'm'
|
when 'grid' then 'm'
|
||||||
when 'square' then 's'
|
when 'square' then 's'
|
||||||
when 'raw' then 'b'
|
when 'base' then 'b'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ namespace :granblue do
|
||||||
namespace :export do
|
namespace :export do
|
||||||
def build_chara_url(id, size)
|
def build_chara_url(id, size)
|
||||||
# Set up URL
|
# 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'
|
extension = '.jpg'
|
||||||
|
|
||||||
directory = 'f' if size.to_s == 'main'
|
directory = 'f' if size.to_s == 'main'
|
||||||
|
|
|
||||||
|
|
@ -69,5 +69,67 @@ namespace :granblue do
|
||||||
task :job, [:size] => :environment do |_t, args|
|
task :job, [:size] => :environment do |_t, args|
|
||||||
write_urls(args[:size])
|
write_urls(args[:size])
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ namespace :granblue do
|
||||||
namespace :export do
|
namespace :export do
|
||||||
def build_summon_url(id, size)
|
def build_summon_url(id, size)
|
||||||
# Set up URL
|
# 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'
|
extension = '.jpg'
|
||||||
|
|
||||||
directory = 'party_main' if size.to_s == 'main'
|
directory = 'party_main' if size.to_s == 'main'
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ namespace :granblue do
|
||||||
namespace :export do
|
namespace :export do
|
||||||
def build_weapon_url(id, size)
|
def build_weapon_url(id, size)
|
||||||
# Set up URL
|
# 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'
|
extension = '.jpg'
|
||||||
|
|
||||||
directory = 'ls' if size.to_s == 'main'
|
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
|
FactoryBot.define do
|
||||||
factory :weapon_key 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
|
end
|
||||||
|
|
@ -1,5 +1,79 @@
|
||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :weapon 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
|
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'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe User, type: :model do
|
RSpec.describe User, type: :model do
|
||||||
pending "add some examples to (or delete) #{__FILE__}"
|
describe 'associations' do
|
||||||
end
|
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