fix blueprints: use correct association names instead of 'object'

This commit is contained in:
Justin Edmund 2025-11-29 17:41:29 -08:00
parent 144b5cab58
commit be5be0c3fe
81 changed files with 11414 additions and 124 deletions

4
.env
View file

@ -1 +1,5 @@
RAILS_LOG_TO_STDOUT=true
OPENAI_API_KEY="not-needed-for-local"
OPENAI_BASE_URL="http://192.168.1.246:8000/v1"
OPENAI_MODEL="cpatonn/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit"

View 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

View 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

View 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

View 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

View file

@ -10,12 +10,12 @@ module Api
end
view :preview do
association :character, name: :object, blueprint: CharacterBlueprint
association :character, blueprint: CharacterBlueprint
end
view :nested do
include_view :mastery_bonuses
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
association :character, blueprint: CharacterBlueprint, view: :full
end
view :uncap do

View file

@ -6,11 +6,11 @@ module Api
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
view :preview do
association :summon, name: :object, blueprint: SummonBlueprint
association :summon, blueprint: SummonBlueprint
end
view :nested do
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
association :summon, blueprint: SummonBlueprint, view: :full
end
view :full do

View file

@ -6,7 +6,7 @@ module Api
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
view :preview do
association :weapon, name: :object, blueprint: WeaponBlueprint
association :weapon, blueprint: WeaponBlueprint
end
view :nested do
@ -24,7 +24,7 @@ module Api
}
end
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
association :weapon, blueprint: WeaponBlueprint, view: :full,
if: ->(_field_name, w, _options) { w.weapon.present? }
association :weapon_keys,

View file

@ -26,6 +26,11 @@ module Api
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response
# Collection errors
rescue_from CollectionErrors::CollectionError do |e|
render json: e.to_hash, status: e.http_status
end
rescue_from GranblueError do |e|
render_error(e)
end
@ -157,6 +162,17 @@ module Api
ensure
Prosopite.finish
end
# Returns pagination metadata for will_paginate collections
# @param collection [ActiveRecord::Relation] Paginated collection using will_paginate
# @return [Hash] Pagination metadata with count, total_pages, and per_page
def pagination_meta(collection)
{
count: collection.total_entries,
total_pages: collection.total_pages,
per_page: collection.limit_value || collection.per_page
}
end
end
end
end

View 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

View 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

View file

@ -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

View 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

View 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

View file

@ -194,22 +194,11 @@ module Api
existing.destroy
end
# Compute the default uncap level based on the incoming character's flags.
if incoming.special
uncap_level = 3
uncap_level = 5 if incoming.ulb
uncap_level = 4 if incoming.flb
else
uncap_level = 4
uncap_level = 6 if incoming.ulb
uncap_level = 5 if incoming.flb
end
grid_character = GridCharacter.create!(
party_id: @party.id,
character_id: incoming.id,
position: resolve_params[:position],
uncap_level: uncap_level
uncap_level: compute_max_uncap_level(incoming)
)
render json: GridCharacterBlueprint.render(grid_character,
root: :grid_character,
@ -248,7 +237,8 @@ module Api
grid_character = GridCharacter.new(
character_params.except(:rings, :awakening).merge(
party_id: @party.id,
character_id: @incoming_character.id
character_id: @incoming_character.id,
uncap_level: compute_max_uncap_level(@incoming_character)
)
)
assign_transformed_attributes(grid_character, processed_params)
@ -256,6 +246,24 @@ module Api
grid_character
end
##
# Computes the maximum uncap level for a character based on its flags.
#
# Special characters (limited/seasonal) have a different uncap progression:
# - Base: 3, FLB: 4, ULB: 5
# Regular characters:
# - Base: 4, FLB: 5, ULB: 6
#
# @param character [Character] the character to compute max uncap for.
# @return [Integer] the maximum uncap level.
def compute_max_uncap_level(character)
if character.special
character.ulb ? 5 : character.flb ? 4 : 3
else
character.ulb ? 6 : character.flb ? 5 : 4
end
end
##
# Assigns raw attributes from the original parameters to the grid character.
#

View file

@ -30,10 +30,9 @@ module Api
# @return [void]
def create
# Build a new grid summon using permitted parameters merged with party and summon IDs.
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
# if it hasn't already been provided.
# Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
grid_summon = build_grid_summon.tap do |gs|
gs.uncap_level ||= max_uncap_level(gs.summon)
gs.uncap_level = max_uncap_level(gs.summon)
end
# If the grid summon is valid (i.e. it passes all validations), then save it normally.

View file

@ -31,7 +31,8 @@ module Api
grid_weapon = GridWeapon.new(
weapon_params.merge(
party_id: @party.id,
weapon_id: @incoming_weapon.id
weapon_id: @incoming_weapon.id,
uncap_level: compute_default_uncap(@incoming_weapon)
)
)

View file

@ -51,7 +51,7 @@ module Api
end
def update_job_skills
throw NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
raise Api::V1::NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
# Determine which incoming keys contain new skills
skill_keys = %w[skill1_id skill2_id skill3_id]
@ -59,47 +59,47 @@ module Api
# If there are new skills, merge them with the existing skills
unless new_skill_keys.empty?
# Load skills ONCE upfront to avoid N+1 queries
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
new_skills_loaded = JobSkill.where(id: new_skill_ids).index_by(&:id)
# Validate all skills exist and are compatible
new_skill_ids.each do |id|
skill = new_skills_loaded[id]
raise ActiveRecord::RecordNotFound.new("Couldn't find JobSkill") unless skill
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job, skill)
end
existing_skills = {
1 => @party.skill1,
2 => @party.skill2,
3 => @party.skill3
}
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
new_skill_ids.map do |id|
skill = JobSkill.find(id)
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job,
skill)
end
positions = extract_positions_from_keys(new_skill_keys)
new_skills = merge_skills_with_existing_skills(existing_skills, new_skill_ids, positions)
# Pass loaded skills instead of IDs
merged = merge_skills_with_loaded_skills(existing_skills, new_skill_ids.map { |id| new_skills_loaded[id] }, positions)
new_skill_ids = new_skills.each_with_object({}) do |(index, skill), memo|
memo["skill#{index}_id"] = skill.id if skill
skill_ids_hash = merged.each_with_object({}) do |(index, skill), memo|
memo["skill#{index}_id"] = skill&.id
end
@party.attributes = new_skill_ids
@party.attributes = skill_ids_hash
end
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
end
def destroy_job_skill
position = job_params[:skill_position].to_i
@party["skill#{position}_id"] = nil
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save
end
private
def merge_skills_with_existing_skills(
existing_skills,
new_skill_ids,
positions
)
new_skills = new_skill_ids.map { |id| JobSkill.find(id) }
def merge_skills_with_loaded_skills(existing_skills, new_skills, positions)
# new_skills is now an array of already-loaded JobSkill objects
new_skills.each_with_index do |skill, index|
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
end
@ -182,7 +182,8 @@ module Api
end
def set
@party = Party.where('id = ?', params[:id]).first
@party = Party.find_by(shortcode: params[:id])
render_not_found_response('party') unless @party
end
def job_params

View file

@ -146,7 +146,14 @@ module Api
def index
query = build_filtered_query(build_common_base_query)
@parties = query.paginate(page: params[:page], per_page: page_size)
render_paginated_parties(@parties, page_size)
render json: Api::V1::PartyBlueprint.render(
@parties,
view: :preview,
root: :results,
meta: pagination_meta(@parties),
current_user: current_user
)
end
# GET /api/v1/parties/favorites
@ -159,7 +166,14 @@ module Api
.distinct
query = build_filtered_query(base_query)
@parties = query.paginate(page: params[:page], per_page: page_size)
render_paginated_parties(@parties, page_size)
render json: Api::V1::PartyBlueprint.render(
@parties,
view: :preview,
root: :results,
meta: pagination_meta(@parties),
current_user: current_user
)
end
# Preview Management

View file

@ -87,11 +87,7 @@ module Api
render json: CharacterBlueprint.render(paginated,
view: :dates,
root: :results,
meta: {
count: count,
total_pages: total_pages(count),
per_page: search_page_size
})
meta: pagination_meta(paginated).merge(count: count))
end
def weapons
@ -126,11 +122,7 @@ module Api
render json: WeaponBlueprint.render(paginated,
view: :dates,
root: :results,
meta: {
count: count,
total_pages: total_pages(count),
per_page: search_page_size
})
meta: pagination_meta(paginated).merge(count: count))
end
def summons
@ -160,11 +152,7 @@ module Api
render json: SummonBlueprint.render(paginated,
view: :dates,
root: :results,
meta: {
count: count,
total_pages: total_pages(count),
per_page: search_page_size
})
meta: pagination_meta(paginated).merge(count: count))
end
def job_skills
@ -248,11 +236,7 @@ module Api
render json: JobSkillBlueprint.render(paginated,
root: :results,
meta: {
count: count,
total_pages: total_pages(count),
per_page: search_page_size
})
meta: pagination_meta(paginated).merge(count: count))
end
def guidebooks
@ -268,20 +252,11 @@ module Api
render json: GuidebookBlueprint.render(paginated,
root: :results,
meta: {
count: count,
total_pages: total_pages(count),
per_page: search_page_size
})
meta: pagination_meta(paginated).merge(count: count))
end
private
def total_pages(count)
per_page = search_page_size
(count.to_f / per_page).ceil
end
# Specify whitelisted properties that can be modified.
def search_params
return {} unless params[:search].present?

View file

@ -31,21 +31,6 @@ module PartyQueryingConcern
options: { apply_defaults: true }).build
end
# Renders paginated parties using PartyBlueprint.
def render_paginated_parties(parties, per_page = COLLECTION_PER_PAGE)
render json: Api::V1::PartyBlueprint.render(
parties,
view: :preview,
root: :results,
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: per_page
},
current_user: current_user
)
end
# Returns a remixed party name based on the current party name and current_user language.
def remixed_name(name)
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }

View 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

View 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

View 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

View 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

View 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

View file

@ -6,6 +6,13 @@ class User < ApplicationRecord
##### ActiveRecord Associations
has_many :parties, dependent: :destroy
has_many :favorites, dependent: :destroy
has_many :collection_characters, dependent: :destroy
has_many :collection_weapons, dependent: :destroy
has_many :collection_summons, dependent: :destroy
has_many :collection_job_accessories, dependent: :destroy
# Note: The crew association will be added when crews feature is implemented
# belongs_to :crew, optional: true
##### ActiveRecord Validations
validates :username,
@ -39,6 +46,15 @@ class User < ApplicationRecord
##### ActiveModel Security
has_secure_password
##### Enums
# Enum for collection privacy levels
enum :collection_privacy, {
everyone: 0,
crew_only: 1,
private_collection: 2
}, prefix: true
##### Instance Methods
def favorite_parties
favorites.map(&:party)
end
@ -50,4 +66,30 @@ class User < ApplicationRecord
def blueprint
UserBlueprint
end
end
# Check if collection is viewable by another user
def collection_viewable_by?(viewer)
return true if self == viewer # Owners can always view their own collection
case collection_privacy
when 'everyone'
true
when 'crew_only'
# Will be implemented when crew feature is added:
# viewer.present? && crew.present? && viewer.crew_id == crew_id
false # For now, crew_only acts like private until crews are implemented
when 'private_collection'
false
else
false
end
end
# Helper method to check if user is in same crew (placeholder for future)
def in_same_crew_as?(other_user)
# Will be implemented when crew feature is added:
# return false unless other_user.present?
# crew.present? && other_user.crew_id == crew_id
false
end
end

View file

@ -4,11 +4,11 @@ class AwsService
class ConfigurationError < StandardError; end
def initialize
Rails.logger.info "Environment: #{Rails.env}"
Rails.logger.debug "Environment: #{Rails.env}"
# Try different methods of getting credentials
creds = get_credentials
Rails.logger.info "Credentials source: #{creds[:source]}"
Rails.logger.debug "Credentials source: #{creds[:source]}"
@s3_client = Aws::S3::Client.new(
region: creds[:region],
@ -44,14 +44,14 @@ class AwsService
# Try Rails credentials first
rails_creds = Rails.application.credentials.dig(:aws)
if rails_creds&.dig(:access_key_id)
Rails.logger.info "Using Rails credentials"
Rails.logger.debug "Using Rails credentials"
return rails_creds.merge(source: 'rails_credentials')
end
# Try string keys
rails_creds = Rails.application.credentials.dig('aws')
if rails_creds&.dig('access_key_id')
Rails.logger.info "Using Rails credentials (string keys)"
Rails.logger.debug "Using Rails credentials (string keys)"
return {
region: rails_creds['region'],
access_key_id: rails_creds['access_key_id'],
@ -63,7 +63,7 @@ class AwsService
# Try environment variables
if ENV['AWS_ACCESS_KEY_ID']
Rails.logger.info "Using environment variables"
Rails.logger.debug "Using environment variables"
return {
region: ENV['AWS_REGION'],
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
@ -75,7 +75,7 @@ class AwsService
# Try alternate environment variable names
if ENV['RAILS_AWS_ACCESS_KEY_ID']
Rails.logger.info "Using Rails-prefixed environment variables"
Rails.logger.debug "Using Rails-prefixed environment variables"
return {
region: ENV['RAILS_AWS_REGION'],
access_key_id: ENV['RAILS_AWS_ACCESS_KEY_ID'],

View file

@ -87,6 +87,18 @@ Rails.application.routes.draw do
post 'parties/:id/grid_update', to: 'parties#grid_update'
delete 'favorites', to: 'favorites#destroy'
# User collection viewing (respects privacy settings)
get 'users/:user_id/collection', to: 'collection#show'
# Collection management for current user
namespace :collection do
resources :characters, controller: '/api/v1/collection_characters'
resources :weapons, controller: '/api/v1/collection_weapons'
resources :summons, controller: '/api/v1/collection_summons'
resources :job_accessories, controller: '/api/v1/collection_job_accessories',
only: [:index, :show, :create, :destroy]
end
end
if Rails.env.development?

View file

@ -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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
ActiveRecord::Schema[8.0].define(version: 2025_09_28_120005) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@ -105,6 +105,77 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
t.index ["skill_id"], name: "index_charge_attacks_on_skill_id"
end
create_table "collection_characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.uuid "character_id", null: false
t.integer "uncap_level", default: 0, null: false
t.integer "transcendence_step", default: 0, null: false
t.boolean "perpetuity", default: false, null: false
t.uuid "awakening_id"
t.integer "awakening_level", default: 1
t.jsonb "ring1", default: {"modifier"=>nil, "strength"=>nil}, null: false
t.jsonb "ring2", default: {"modifier"=>nil, "strength"=>nil}, null: false
t.jsonb "ring3", default: {"modifier"=>nil, "strength"=>nil}, null: false
t.jsonb "ring4", default: {"modifier"=>nil, "strength"=>nil}, null: false
t.jsonb "earring", default: {"modifier"=>nil, "strength"=>nil}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["awakening_id"], name: "index_collection_characters_on_awakening_id"
t.index ["character_id"], name: "index_collection_characters_on_character_id"
t.index ["user_id", "character_id"], name: "index_collection_characters_on_user_id_and_character_id", unique: true
t.index ["user_id"], name: "index_collection_characters_on_user_id"
end
create_table "collection_job_accessories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.uuid "job_accessory_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["job_accessory_id"], name: "index_collection_job_accessories_on_job_accessory_id"
t.index ["user_id", "job_accessory_id"], name: "idx_collection_job_acc_user_accessory", unique: true
t.index ["user_id"], name: "index_collection_job_accessories_on_user_id"
end
create_table "collection_summons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.uuid "summon_id", null: false
t.integer "uncap_level", default: 0, null: false
t.integer "transcendence_step", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["summon_id"], name: "index_collection_summons_on_summon_id"
t.index ["user_id", "summon_id"], name: "index_collection_summons_on_user_id_and_summon_id"
t.index ["user_id"], name: "index_collection_summons_on_user_id"
end
create_table "collection_weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.uuid "weapon_id", null: false
t.integer "uncap_level", default: 0, null: false
t.integer "transcendence_step", default: 0
t.uuid "weapon_key1_id"
t.uuid "weapon_key2_id"
t.uuid "weapon_key3_id"
t.uuid "weapon_key4_id"
t.uuid "awakening_id"
t.integer "awakening_level", default: 1, null: false
t.integer "ax_modifier1"
t.float "ax_strength1"
t.integer "ax_modifier2"
t.float "ax_strength2"
t.integer "element"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["awakening_id"], name: "index_collection_weapons_on_awakening_id"
t.index ["user_id", "weapon_id"], name: "index_collection_weapons_on_user_id_and_weapon_id"
t.index ["user_id"], name: "index_collection_weapons_on_user_id"
t.index ["weapon_id"], name: "index_collection_weapons_on_weapon_id"
t.index ["weapon_key1_id"], name: "index_collection_weapons_on_weapon_key1_id"
t.index ["weapon_key2_id"], name: "index_collection_weapons_on_weapon_key2_id"
t.index ["weapon_key3_id"], name: "index_collection_weapons_on_weapon_key3_id"
t.index ["weapon_key4_id"], name: "index_collection_weapons_on_weapon_key4_id"
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
@ -569,6 +640,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
t.integer "gender", default: 0, null: false
t.string "theme", default: "system", null: false
t.integer "role", default: 1, null: false
t.integer "collection_privacy", default: 0, null: false
t.index ["collection_privacy"], name: "index_users_on_collection_privacy"
end
create_table "weapon_awakenings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -655,6 +728,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do
add_foreign_key "character_skills", "skills", column: "alt_skill_id"
add_foreign_key "charge_attacks", "skills"
add_foreign_key "charge_attacks", "skills", column: "alt_skill_id"
add_foreign_key "collection_characters", "awakenings"
add_foreign_key "collection_characters", "characters"
add_foreign_key "collection_characters", "users"
add_foreign_key "collection_job_accessories", "job_accessories"
add_foreign_key "collection_job_accessories", "users"
add_foreign_key "collection_summons", "summons"
add_foreign_key "collection_summons", "users"
add_foreign_key "collection_weapons", "awakenings"
add_foreign_key "collection_weapons", "users"
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key1_id"
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key2_id"
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key3_id"
add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key4_id"
add_foreign_key "collection_weapons", "weapons"
add_foreign_key "effects", "effects", column: "effect_family_id"
add_foreign_key "favorites", "parties"
add_foreign_key "favorites", "users"

View file

@ -73,6 +73,11 @@ def load_csv_for(model_class, csv_filename, unique_key = :granblue_id, use_id: f
# Remove the :id attribute unless we want to preserve it (for fixed canonical IDs).
attrs.except!(:id) unless use_id
# Skip records with missing associations (for test data)
if model_class == WeaponAwakening
next unless Weapon.exists?(attrs[:weapon_id]) && Awakening.exists?(attrs[:awakening_id])
end
# Find or create the record based on the unique key.
record = model_class.find_or_create_by!(unique_key => attrs[unique_key]) do |r|
# Assign all attributes except the unique_key.

View file

@ -50,7 +50,7 @@ def seed_weapons
w.max_atk = row['max_hp']
w.max_atk_flb = row['max_hp_flb']
w.max_atk_ulb = row['max_hp_ulb']
w.recruits_id = row['recruits_id']
w.recruits = row['recruits']
w.save
end

187
docs/README.md Normal file
View 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
View 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
```

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

400
docs/importers.md Normal file
View 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
View 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

View 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

View 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

View 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
View 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
View 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
View 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

View file

@ -29,6 +29,25 @@ module Granblue
# @return [Array<String>] Available image size variants
SIZES = %w[main grid square].freeze
# Class-level AWS service instance (shared across all downloaders)
class << self
def aws_service
@aws_service_mutex ||= Mutex.new
@aws_service_mutex.synchronize do
@aws_service ||= AwsService.new
end
end
# Reset the shared AWS service (useful for testing)
# @return [void]
def reset_aws_service
@aws_service_mutex ||= Mutex.new
@aws_service_mutex.synchronize do
@aws_service = nil
end
end
end
# Initialize a new downloader instance
# @param id [String] ID of the object to download images for
# @param test_mode [Boolean] When true, only logs actions without downloading
@ -42,7 +61,7 @@ module Granblue
@verbose = verbose
@storage = storage
@logger = logger || Logger.new($stdout) # fallback logger
@aws_service = AwsService.new
@aws_service = self.class.aws_service if store_in_s3?
ensure_directories_exist unless @test_mode
end
@ -158,7 +177,8 @@ module Granblue
def ensure_directories_exist
return unless store_locally?
SIZES.each do |size|
sizes = self.class::SIZES rescue SIZES
sizes.each do |size|
FileUtils.mkdir_p(download_path(size))
end
end
@ -169,6 +189,12 @@ module Granblue
%i[local both].include?(@storage)
end
# Check if S3 storage is being used
# @return [Boolean] true if storing to S3
def store_in_s3?
%i[s3 both].include?(@storage)
end
# Get local download path for a size
# @param size [String] Image size variant
# @return [String] Local directory path

View file

@ -12,6 +12,8 @@ module Granblue
# @note Character images come in multiple variants (_01, _02, etc.) based on uncap status
# @note Supports FLB (5★) and ULB (6★) art variants when available
class CharacterDownloader < BaseDownloader
# Override SIZES to include 'detail' for detail images
SIZES = %w[main grid square detail].freeze
# Downloads images for all variants of a character based on their uncap status.
# Overrides {BaseDownloader#download} to handle character-specific variants.
#
@ -66,7 +68,7 @@ module Granblue
sizes.each_with_index do |size, index|
path = download_path(size)
url = build_variant_url(variant_id, size)
process_download(url, size, path, last: index == SIZES.size - 1)
process_download(url, size, path, last: index == sizes.size - 1)
end
end
@ -94,7 +96,7 @@ module Granblue
# Gets base URL for character assets
# @return [String] Base URL for character images
def base_url
'http://gbf.game-a.mbga.jp/assets/img/sp/assets/npc'
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/npc'
end
# Gets directory name for a size variant

View 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

View file

@ -12,6 +12,8 @@ module Granblue
# @note Summon images come in multiple variants based on uncap status
# @note Supports ULB (5★) and transcendence variants when available
class SummonDownloader < BaseDownloader
# Override SIZES to include 'wide' for m directory images and 'detail' for detail images
SIZES = %w[main grid wide square detail].freeze
# Downloads images for all variants of a summon based on their uncap status.
# Overrides {BaseDownloader#download} to handle summon-specific variants.
#
@ -68,7 +70,7 @@ module Granblue
sizes.each_with_index do |size, index|
path = download_path(size)
url = build_variant_url(variant_id, size)
process_download(url, size, path, last: index == SIZES.size - 1)
process_download(url, size, path, last: index == sizes.size - 1)
end
end
@ -95,18 +97,19 @@ module Granblue
# Gets base URL for summon assets
# @return [String] Base URL for summon images
def base_url
'http://gbf.game-a.mbga.jp/assets/img/sp/assets/summon'
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon'
end
# Gets directory name for a size variant
#
# @param size [String] Image size variant
# @return [String] Directory name in game asset URL structure
# @note Maps "main" -> "party_main", "grid" -> "party_sub", "square" -> "s"
# @note Maps "main" -> "ls", "grid" -> "party_sub", "wide" -> "m", "square" -> "s"
def directory_for_size(size)
case size.to_s
when 'main' then 'ls'
when 'grid' then 'm'
when 'grid' then 'party_sub'
when 'wide' then 'm'
when 'square' then 's'
when 'detail' then 'detail'
end

View file

@ -13,6 +13,8 @@ module Granblue
# @note Supports transcendence variants and element-specific variants
# @see ElementalWeaponDownloader for handling multi-element weapons
class WeaponDownloader < BaseDownloader
# Override SIZES to include 'base' for b directory images
SIZES = %w[main grid square base].freeze
# Downloads images for all variants of a weapon based on their uncap status.
# Overrides {BaseDownloader#download} to handle weapon-specific variants.
#
@ -66,18 +68,18 @@ module Granblue
sizes.each_with_index do |size, index|
path = download_path(size)
url = build_variant_url(variant_id, size)
process_download(url, size, path, last: index == SIZES.size - 1)
process_download(url, size, path, last: index == sizes.size - 1)
end
end
# Builds URL for a specific variant and size
#
# @param variant_id [String] Weapon variant ID
# @param size [String] Image size variant ("main", "grid", "square", or "raw")
# @param size [String] Image size variant ("main", "grid", "square", or "base")
# @return [String] Complete URL for downloading the image
def build_variant_url(variant_id, size)
directory = directory_for_size(size)
if size == 'raw'
if size == 'base'
"#{@base_url}/#{directory}/#{variant_id}.png"
else
"#{@base_url}/#{directory}/#{variant_id}.jpg"
@ -93,20 +95,20 @@ module Granblue
# Gets base URL for weapon assets
# @return [String] Base URL for weapon images
def base_url
'http://gbf.game-a.mbga.jp/assets/img/sp/assets/weapon'
'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/weapon'
end
# Gets directory name for a size variant
#
# @param size [String] Image size variant
# @return [String] Directory name in game asset URL structure
# @note Maps "main" -> "ls", "grid" -> "m", "square" -> "s"
# @note Maps "main" -> "ls", "grid" -> "m", "square" -> "s", "base" -> "b"
def directory_for_size(size)
case size.to_s
when 'main' then 'ls'
when 'grid' then 'm'
when 'square' then 's'
when 'raw' then 'b'
when 'base' then 'b'
end
end
end

View file

@ -2,7 +2,7 @@ namespace :granblue do
namespace :export do
def build_chara_url(id, size)
# Set up URL
base_url = 'http://gbf.game-a.mbga.jp/assets/img/sp/assets/npc'
base_url = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/npc'
extension = '.jpg'
directory = 'f' if size.to_s == 'main'

View file

@ -69,5 +69,67 @@ namespace :granblue do
task :job, [:size] => :environment do |_t, args|
write_urls(args[:size])
end
desc 'Download job images using the JobDownloader'
task :job_images, [:id, :test_mode, :verbose, :storage, :size] => :environment do |_t, args|
require 'granblue/downloaders/job_downloader'
id = args[:id]
test_mode = args[:test_mode] == 'true'
verbose = args[:verbose] != 'false' # Default to true
storage = (args[:storage] || 'both').to_sym
size = args[:size]
logger = Logger.new($stdout)
if id
# Download a specific job
job = Job.find_by(granblue_id: id)
if job
logger.info "Downloading images for job: #{job.name_en} (#{job.granblue_id})"
logger.info "Test mode: #{test_mode}" if test_mode
logger.info "Storage: #{storage}"
logger.info "Size: #{size}" if size
downloader = Granblue::Downloaders::JobDownloader.new(
job.granblue_id,
test_mode: test_mode,
verbose: verbose,
storage: storage,
logger: logger
)
downloader.download(size)
else
logger.error "Job not found with ID: #{id}"
exit 1
end
else
# Download all jobs
jobs = Job.all.order(:granblue_id)
total = jobs.count
logger.info "Found #{total} jobs to process"
logger.info "Test mode: #{test_mode}" if test_mode
logger.info "Storage: #{storage}"
logger.info "Size: #{size}" if size
jobs.each_with_index do |job, index|
logger.info "[#{index + 1}/#{total}] Processing: #{job.name_en} (#{job.granblue_id})"
downloader = Granblue::Downloaders::JobDownloader.new(
job.granblue_id,
test_mode: test_mode,
verbose: verbose,
storage: storage,
logger: logger
)
downloader.download(size)
# Add a small delay to avoid hammering the server
sleep(0.5) unless test_mode
end
end
logger.info "Job image download completed!"
end
end
end

View file

@ -2,7 +2,7 @@ namespace :granblue do
namespace :export do
def build_summon_url(id, size)
# Set up URL
base_url = 'http://gbf.game-a.mbga.jp/assets/img/sp/assets/summon'
base_url = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon'
extension = '.jpg'
directory = 'party_main' if size.to_s == 'main'

View file

@ -2,7 +2,7 @@ namespace :granblue do
namespace :export do
def build_weapon_url(id, size)
# Set up URL
base_url = 'http://gbf.game-a.mbga.jp/assets/img/sp/assets/weapon'
base_url = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/weapon'
extension = '.jpg'
directory = 'ls' if size.to_s == 'main'

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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

View file

@ -1,5 +1,24 @@
FactoryBot.define do
factory :weapon_key do
sequence(:name_en) { |n| "Pendulum of #{['Strife', 'Sagacity', 'Prosperity', 'Forbiddance', 'Temperament'].sample}" }
name_jp { "" }
slot { rand(1..3) }
group { rand(1..5) }
order { rand(1..20) }
sequence(:slug) { |n| "key-#{n}" }
sequence(:granblue_id) { |n| n.to_s }
series { [3, 27] } # Opus and Draconic weapons
trait :opus_key do
series { [3] }
end
trait :draconic_key do
series { [27] }
end
trait :universal_key do
series { [3, 27, 99] } # Works with more weapon series
end
end
end
end

View file

@ -1,5 +1,79 @@
FactoryBot.define do
factory :weapon do
sequence(:granblue_id) { |n| "104#{n.to_s.rjust(7, '0')}" }
sequence(:name_en) { |n| "Test Weapon #{n}" }
name_jp { "テスト武器" }
rarity { 4 } # SSR
element { 1 } # Fire
proficiency { 1 } # Sabre
series { 99 } # Gacha
# Release info
release_date { 1.year.ago }
flb_date { 6.months.ago }
ulb_date { nil }
transcendence_date { nil }
# Max stats
max_hp { 300 }
max_atk { 2400 }
max_hp_flb { 360 }
max_atk_flb { 2900 }
max_hp_ulb { nil }
max_atk_ulb { nil }
# Capabilities
flb { true }
ulb { false }
transcendence { false }
ax { false }
# Skill info
max_skill_level { 15 }
max_level { 150 }
trait :r do
rarity { 2 }
max_hp { 120 }
max_atk { 960 }
max_hp_flb { nil }
max_atk_flb { nil }
flb { false }
end
trait :sr do
rarity { 3 }
max_hp { 200 }
max_atk { 1600 }
max_hp_flb { 240 }
max_atk_flb { 1920 }
end
trait :ssr do
rarity { 4 }
end
trait :transcendable do
ulb { true }
transcendence { true }
ulb_date { 3.months.ago }
transcendence_date { 1.month.ago }
max_hp_ulb { 420 }
max_atk_ulb { 3400 }
max_level { 200 }
max_skill_level { 20 }
end
trait :opus do
series { 3 } # dark-opus
end
trait :draconic do
series { 27 } # draconic
end
trait :ax_weapon do
ax { true }
end
end
end
end

View 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

View 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

View 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

View 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

View file

@ -1,5 +1,135 @@
require 'rails_helper'
RSpec.describe User, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end
describe 'associations' do
it { should have_many(:parties).dependent(:destroy) }
it { should have_many(:favorites).dependent(:destroy) }
it { should have_many(:collection_characters).dependent(:destroy) }
it { should have_many(:collection_weapons).dependent(:destroy) }
it { should have_many(:collection_summons).dependent(:destroy) }
it { should have_many(:collection_job_accessories).dependent(:destroy) }
end
describe 'validations' do
it { should validate_presence_of(:username) }
it { should validate_length_of(:username).is_at_least(3).is_at_most(26) }
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email).ignoring_case_sensitivity }
end
describe 'collection_privacy enum' do
it { should define_enum_for(:collection_privacy).with_values(everyone: 0, crew_only: 1, private_collection: 2).with_prefix(true) }
it 'defaults to everyone' do
user = build(:user)
expect(user.collection_privacy).to eq('everyone')
end
it 'allows setting to crew_only' do
user = create(:user, collection_privacy: :crew_only)
expect(user.collection_privacy).to eq('crew_only')
end
it 'allows setting to private_collection' do
user = create(:user, collection_privacy: :private_collection)
expect(user.collection_privacy).to eq('private_collection')
end
end
describe '#collection_viewable_by?' do
let(:owner) { create(:user, collection_privacy: :everyone) }
let(:viewer) { create(:user) }
context 'when viewer is the owner' do
it 'returns true regardless of privacy setting' do
owner.update(collection_privacy: :private_collection)
expect(owner.collection_viewable_by?(owner)).to be true
end
end
context 'when collection privacy is everyone' do
it 'returns true for any viewer' do
owner.update(collection_privacy: :everyone)
expect(owner.collection_viewable_by?(viewer)).to be true
end
it 'returns true for unauthenticated users (nil)' do
owner.update(collection_privacy: :everyone)
expect(owner.collection_viewable_by?(nil)).to be true
end
end
context 'when collection privacy is private_collection' do
it 'returns false for non-owner' do
owner.update(collection_privacy: :private_collection)
expect(owner.collection_viewable_by?(viewer)).to be false
end
it 'returns false for unauthenticated users' do
owner.update(collection_privacy: :private_collection)
expect(owner.collection_viewable_by?(nil)).to be false
end
end
context 'when collection privacy is crew_only' do
it 'returns false for non-owner (crews not yet implemented)' do
owner.update(collection_privacy: :crew_only)
expect(owner.collection_viewable_by?(viewer)).to be false
end
it 'returns false for unauthenticated users' do
owner.update(collection_privacy: :crew_only)
expect(owner.collection_viewable_by?(nil)).to be false
end
end
end
describe '#in_same_crew_as?' do
let(:user1) { create(:user) }
let(:user2) { create(:user) }
it 'returns false (placeholder until crews are implemented)' do
expect(user1.in_same_crew_as?(user2)).to be false
end
it 'returns false when other_user is present' do
expect(user1.in_same_crew_as?(user2)).to be false
end
end
describe 'collection associations behavior' do
let(:user) { create(:user) }
it 'destroys collection_characters when user is destroyed' do
create(:collection_character, user: user)
expect { user.destroy }.to change(CollectionCharacter, :count).by(-1)
end
it 'destroys collection_weapons when user is destroyed' do
create(:collection_weapon, user: user)
expect { user.destroy }.to change(CollectionWeapon, :count).by(-1)
end
it 'destroys collection_summons when user is destroyed' do
create(:collection_summon, user: user)
expect { user.destroy }.to change(CollectionSummon, :count).by(-1)
end
it 'destroys collection_job_accessories when user is destroyed' do
create(:collection_job_accessory, user: user)
expect { user.destroy }.to change(CollectionJobAccessory, :count).by(-1)
end
end
describe '#admin?' do
it 'returns true when role is 9' do
user = create(:user, role: 9)
expect(user.admin?).to be true
end
it 'returns false when role is not 9' do
user = create(:user, role: 0)
expect(user.admin?).to be false
end
end
end

View 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

View 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

View 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

View 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

View 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