From be5be0c3fe67ed2738e74ac1784a7761ae162fd9 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sat, 29 Nov 2025 17:41:29 -0800 Subject: [PATCH] fix blueprints: use correct association names instead of 'object' --- .env | 4 + .../api/v1/collection_character_blueprint.rb | 24 + .../v1/collection_job_accessory_blueprint.rb | 11 + .../api/v1/collection_summon_blueprint.rb | 16 + .../api/v1/collection_weapon_blueprint.rb | 32 + .../api/v1/grid_character_blueprint.rb | 4 +- .../api/v1/grid_summon_blueprint.rb | 4 +- .../api/v1/grid_weapon_blueprint.rb | 4 +- app/controllers/api/v1/api_controller.rb | 16 + .../v1/collection_characters_controller.rb | 86 + .../api/v1/collection_controller.rb | 79 + .../collection_job_accessories_controller.rb | 60 + .../api/v1/collection_summons_controller.rb | 75 + .../api/v1/collection_weapons_controller.rb | 81 + .../api/v1/grid_characters_controller.rb | 34 +- .../api/v1/grid_summons_controller.rb | 5 +- .../api/v1/grid_weapons_controller.rb | 3 +- app/controllers/api/v1/jobs_controller.rb | 45 +- app/controllers/api/v1/parties_controller.rb | 18 +- app/controllers/api/v1/search_controller.rb | 35 +- .../concerns/party_querying_concern.rb | 15 - app/errors/collection_errors.rb | 48 + app/models/collection_character.rb | 57 + app/models/collection_job_accessory.rb | 14 + app/models/collection_summon.rb | 34 + app/models/collection_weapon.rb | 109 ++ app/models/user.rb | 44 +- app/services/aws_service.rb | 12 +- config/routes.rb | 12 + ...8120000_add_collection_privacy_to_users.rb | 7 + ...0928120001_create_collection_characters.rb | 23 + ...0250928120002_create_collection_weapons.rb | 28 + ...0250928120003_create_collection_summons.rb | 14 + ...20004_create_collection_job_accessories.rb | 13 + ...que_constraint_from_weapons_and_summons.rb | 11 + db/schema.rb | 89 +- db/seed/canonical.rb | 5 + db/seeds.rb | 2 +- docs/README.md | 187 +++ docs/downloaders.md | 368 +++++ .../artifacts-feature-implementation.md | 1179 +++++++++++++ .../collection-tracking-implementation.md | 1043 ++++++++++++ .../crew-feature-implementation.md | 1466 +++++++++++++++++ docs/importers.md | 400 +++++ docs/parsers.md | 468 ++++++ docs/planning/artifacts-feature-plan.md | 385 +++++ docs/planning/collection-tracking-plan.md | 356 ++++ docs/planning/crew-feature-plan.md | 458 +++++ docs/rake-tasks.md | 592 +++++++ docs/rspec-test-analysis.md | 250 +++ docs/transformers.md | 560 +++++++ lib/granblue/downloaders/base_downloader.rb | 30 +- .../downloaders/character_downloader.rb | 6 +- lib/granblue/downloaders/job_downloader.rb | 171 ++ lib/granblue/downloaders/summon_downloader.rb | 11 +- lib/granblue/downloaders/weapon_downloader.rb | 14 +- lib/tasks/export_character.rake | 2 +- lib/tasks/export_job.rake | 62 + lib/tasks/export_summon.rake | 2 +- lib/tasks/export_weapon.rake | 2 +- spec/factories/awakenings.rb | 21 + spec/factories/characters.rb | 51 + spec/factories/collection_characters.rb | 78 + spec/factories/collection_job_accessories.rb | 9 + spec/factories/collection_summons.rb | 70 + spec/factories/collection_weapons.rb | 100 ++ spec/factories/job_accessories.rb | 20 + spec/factories/jobs.rb | 17 + spec/factories/summons.rb | 58 + spec/factories/weapon_keys.rb | 23 +- spec/factories/weapons.rb | 78 +- spec/models/collection_character_spec.rb | 194 +++ spec/models/collection_job_accessory_spec.rb | 69 + spec/models/collection_summon_spec.rb | 131 ++ spec/models/collection_weapon_spec.rb | 239 +++ spec/models/user_spec.rb | 134 +- .../collection_characters_controller_spec.rb | 258 +++ spec/requests/collection_controller_spec.rb | 166 ++ ...lection_job_accessories_controller_spec.rb | 149 ++ .../collection_summons_controller_spec.rb | 214 +++ .../collection_weapons_controller_spec.rb | 274 +++ 81 files changed, 11414 insertions(+), 124 deletions(-) create mode 100644 app/blueprints/api/v1/collection_character_blueprint.rb create mode 100644 app/blueprints/api/v1/collection_job_accessory_blueprint.rb create mode 100644 app/blueprints/api/v1/collection_summon_blueprint.rb create mode 100644 app/blueprints/api/v1/collection_weapon_blueprint.rb create mode 100644 app/controllers/api/v1/collection_characters_controller.rb create mode 100644 app/controllers/api/v1/collection_controller.rb create mode 100644 app/controllers/api/v1/collection_job_accessories_controller.rb create mode 100644 app/controllers/api/v1/collection_summons_controller.rb create mode 100644 app/controllers/api/v1/collection_weapons_controller.rb create mode 100644 app/errors/collection_errors.rb create mode 100644 app/models/collection_character.rb create mode 100644 app/models/collection_job_accessory.rb create mode 100644 app/models/collection_summon.rb create mode 100644 app/models/collection_weapon.rb create mode 100644 db/migrate/20250928120000_add_collection_privacy_to_users.rb create mode 100644 db/migrate/20250928120001_create_collection_characters.rb create mode 100644 db/migrate/20250928120002_create_collection_weapons.rb create mode 100644 db/migrate/20250928120003_create_collection_summons.rb create mode 100644 db/migrate/20250928120004_create_collection_job_accessories.rb create mode 100644 db/migrate/20250928120005_remove_unique_constraint_from_weapons_and_summons.rb create mode 100644 docs/README.md create mode 100644 docs/downloaders.md create mode 100644 docs/implementation/artifacts-feature-implementation.md create mode 100644 docs/implementation/collection-tracking-implementation.md create mode 100644 docs/implementation/crew-feature-implementation.md create mode 100644 docs/importers.md create mode 100644 docs/parsers.md create mode 100644 docs/planning/artifacts-feature-plan.md create mode 100644 docs/planning/collection-tracking-plan.md create mode 100644 docs/planning/crew-feature-plan.md create mode 100644 docs/rake-tasks.md create mode 100644 docs/rspec-test-analysis.md create mode 100644 docs/transformers.md create mode 100644 lib/granblue/downloaders/job_downloader.rb create mode 100644 spec/factories/awakenings.rb create mode 100644 spec/factories/characters.rb create mode 100644 spec/factories/collection_characters.rb create mode 100644 spec/factories/collection_job_accessories.rb create mode 100644 spec/factories/collection_summons.rb create mode 100644 spec/factories/collection_weapons.rb create mode 100644 spec/factories/job_accessories.rb create mode 100644 spec/factories/jobs.rb create mode 100644 spec/factories/summons.rb create mode 100644 spec/models/collection_character_spec.rb create mode 100644 spec/models/collection_job_accessory_spec.rb create mode 100644 spec/models/collection_summon_spec.rb create mode 100644 spec/models/collection_weapon_spec.rb create mode 100644 spec/requests/collection_characters_controller_spec.rb create mode 100644 spec/requests/collection_controller_spec.rb create mode 100644 spec/requests/collection_job_accessories_controller_spec.rb create mode 100644 spec/requests/collection_summons_controller_spec.rb create mode 100644 spec/requests/collection_weapons_controller_spec.rb diff --git a/.env b/.env index acffc1d..09ebe62 100644 --- a/.env +++ b/.env @@ -1 +1,5 @@ RAILS_LOG_TO_STDOUT=true + +OPENAI_API_KEY="not-needed-for-local" +OPENAI_BASE_URL="http://192.168.1.246:8000/v1" +OPENAI_MODEL="cpatonn/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit" diff --git a/app/blueprints/api/v1/collection_character_blueprint.rb b/app/blueprints/api/v1/collection_character_blueprint.rb new file mode 100644 index 0000000..30ca386 --- /dev/null +++ b/app/blueprints/api/v1/collection_character_blueprint.rb @@ -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 \ No newline at end of file diff --git a/app/blueprints/api/v1/collection_job_accessory_blueprint.rb b/app/blueprints/api/v1/collection_job_accessory_blueprint.rb new file mode 100644 index 0000000..487340a --- /dev/null +++ b/app/blueprints/api/v1/collection_job_accessory_blueprint.rb @@ -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 \ No newline at end of file diff --git a/app/blueprints/api/v1/collection_summon_blueprint.rb b/app/blueprints/api/v1/collection_summon_blueprint.rb new file mode 100644 index 0000000..ac76e10 --- /dev/null +++ b/app/blueprints/api/v1/collection_summon_blueprint.rb @@ -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 \ No newline at end of file diff --git a/app/blueprints/api/v1/collection_weapon_blueprint.rb b/app/blueprints/api/v1/collection_weapon_blueprint.rb new file mode 100644 index 0000000..837907a --- /dev/null +++ b/app/blueprints/api/v1/collection_weapon_blueprint.rb @@ -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 \ No newline at end of file diff --git a/app/blueprints/api/v1/grid_character_blueprint.rb b/app/blueprints/api/v1/grid_character_blueprint.rb index b9cc458..accff5e 100644 --- a/app/blueprints/api/v1/grid_character_blueprint.rb +++ b/app/blueprints/api/v1/grid_character_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/grid_summon_blueprint.rb b/app/blueprints/api/v1/grid_summon_blueprint.rb index a4c3df1..637cfd5 100644 --- a/app/blueprints/api/v1/grid_summon_blueprint.rb +++ b/app/blueprints/api/v1/grid_summon_blueprint.rb @@ -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 diff --git a/app/blueprints/api/v1/grid_weapon_blueprint.rb b/app/blueprints/api/v1/grid_weapon_blueprint.rb index f5046c4..861b10e 100644 --- a/app/blueprints/api/v1/grid_weapon_blueprint.rb +++ b/app/blueprints/api/v1/grid_weapon_blueprint.rb @@ -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, diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index ec4f1f0..843a370 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -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 diff --git a/app/controllers/api/v1/collection_characters_controller.rb b/app/controllers/api/v1/collection_characters_controller.rb new file mode 100644 index 0000000..62ff59e --- /dev/null +++ b/app/controllers/api/v1/collection_characters_controller.rb @@ -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 diff --git a/app/controllers/api/v1/collection_controller.rb b/app/controllers/api/v1/collection_controller.rb new file mode 100644 index 0000000..91eb10b --- /dev/null +++ b/app/controllers/api/v1/collection_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/api/v1/collection_job_accessories_controller.rb b/app/controllers/api/v1/collection_job_accessories_controller.rb new file mode 100644 index 0000000..56d9136 --- /dev/null +++ b/app/controllers/api/v1/collection_job_accessories_controller.rb @@ -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 diff --git a/app/controllers/api/v1/collection_summons_controller.rb b/app/controllers/api/v1/collection_summons_controller.rb new file mode 100644 index 0000000..4d86952 --- /dev/null +++ b/app/controllers/api/v1/collection_summons_controller.rb @@ -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 diff --git a/app/controllers/api/v1/collection_weapons_controller.rb b/app/controllers/api/v1/collection_weapons_controller.rb new file mode 100644 index 0000000..773c376 --- /dev/null +++ b/app/controllers/api/v1/collection_weapons_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/api/v1/grid_characters_controller.rb b/app/controllers/api/v1/grid_characters_controller.rb index e5555f9..6724be2 100644 --- a/app/controllers/api/v1/grid_characters_controller.rb +++ b/app/controllers/api/v1/grid_characters_controller.rb @@ -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. # diff --git a/app/controllers/api/v1/grid_summons_controller.rb b/app/controllers/api/v1/grid_summons_controller.rb index ab49093..f30e6f7 100644 --- a/app/controllers/api/v1/grid_summons_controller.rb +++ b/app/controllers/api/v1/grid_summons_controller.rb @@ -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. diff --git a/app/controllers/api/v1/grid_weapons_controller.rb b/app/controllers/api/v1/grid_weapons_controller.rb index 18d148f..f6499ed 100644 --- a/app/controllers/api/v1/grid_weapons_controller.rb +++ b/app/controllers/api/v1/grid_weapons_controller.rb @@ -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) ) ) diff --git a/app/controllers/api/v1/jobs_controller.rb b/app/controllers/api/v1/jobs_controller.rb index 918feef..3eaf8ef 100644 --- a/app/controllers/api/v1/jobs_controller.rb +++ b/app/controllers/api/v1/jobs_controller.rb @@ -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 diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index e255d90..272cd19 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -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 diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index f8ec23b..719b130 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -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? diff --git a/app/controllers/concerns/party_querying_concern.rb b/app/controllers/concerns/party_querying_concern.rb index daead85..d508384 100644 --- a/app/controllers/concerns/party_querying_concern.rb +++ b/app/controllers/concerns/party_querying_concern.rb @@ -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 } diff --git a/app/errors/collection_errors.rb b/app/errors/collection_errors.rb new file mode 100644 index 0000000..63e6026 --- /dev/null +++ b/app/errors/collection_errors.rb @@ -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 \ No newline at end of file diff --git a/app/models/collection_character.rb b/app/models/collection_character.rb new file mode 100644 index 0000000..3951ba8 --- /dev/null +++ b/app/models/collection_character.rb @@ -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 \ No newline at end of file diff --git a/app/models/collection_job_accessory.rb b/app/models/collection_job_accessory.rb new file mode 100644 index 0000000..5056626 --- /dev/null +++ b/app/models/collection_job_accessory.rb @@ -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 \ No newline at end of file diff --git a/app/models/collection_summon.rb b/app/models/collection_summon.rb new file mode 100644 index 0000000..1507b95 --- /dev/null +++ b/app/models/collection_summon.rb @@ -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 \ No newline at end of file diff --git a/app/models/collection_weapon.rb b/app/models/collection_weapon.rb new file mode 100644 index 0000000..d8f3913 --- /dev/null +++ b/app/models/collection_weapon.rb @@ -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 \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 48c4f5f..d0714e1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 \ No newline at end of file diff --git a/app/services/aws_service.rb b/app/services/aws_service.rb index 4fc0f93..d108d28 100644 --- a/app/services/aws_service.rb +++ b/app/services/aws_service.rb @@ -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'], diff --git a/config/routes.rb b/config/routes.rb index 07eb0bf..2279e9d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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? diff --git a/db/migrate/20250928120000_add_collection_privacy_to_users.rb b/db/migrate/20250928120000_add_collection_privacy_to_users.rb new file mode 100644 index 0000000..88c8037 --- /dev/null +++ b/db/migrate/20250928120000_add_collection_privacy_to_users.rb @@ -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 \ No newline at end of file diff --git a/db/migrate/20250928120001_create_collection_characters.rb b/db/migrate/20250928120001_create_collection_characters.rb new file mode 100644 index 0000000..cc9aba1 --- /dev/null +++ b/db/migrate/20250928120001_create_collection_characters.rb @@ -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 \ No newline at end of file diff --git a/db/migrate/20250928120002_create_collection_weapons.rb b/db/migrate/20250928120002_create_collection_weapons.rb new file mode 100644 index 0000000..40d85f8 --- /dev/null +++ b/db/migrate/20250928120002_create_collection_weapons.rb @@ -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 \ No newline at end of file diff --git a/db/migrate/20250928120003_create_collection_summons.rb b/db/migrate/20250928120003_create_collection_summons.rb new file mode 100644 index 0000000..1ce4503 --- /dev/null +++ b/db/migrate/20250928120003_create_collection_summons.rb @@ -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 \ No newline at end of file diff --git a/db/migrate/20250928120004_create_collection_job_accessories.rb b/db/migrate/20250928120004_create_collection_job_accessories.rb new file mode 100644 index 0000000..36f174e --- /dev/null +++ b/db/migrate/20250928120004_create_collection_job_accessories.rb @@ -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 \ No newline at end of file diff --git a/db/migrate/20250928120005_remove_unique_constraint_from_weapons_and_summons.rb b/db/migrate/20250928120005_remove_unique_constraint_from_weapons_and_summons.rb new file mode 100644 index 0000000..eac1131 --- /dev/null +++ b/db/migrate/20250928120005_remove_unique_constraint_from_weapons_and_summons.rb @@ -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 \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index 2fe0196..2434103 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do +ActiveRecord::Schema[8.0].define(version: 2025_09_28_120005) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -105,6 +105,77 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do t.index ["skill_id"], name: "index_charge_attacks_on_skill_id" end + create_table "collection_characters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "character_id", null: false + t.integer "uncap_level", default: 0, null: false + t.integer "transcendence_step", default: 0, null: false + t.boolean "perpetuity", default: false, null: false + t.uuid "awakening_id" + t.integer "awakening_level", default: 1 + t.jsonb "ring1", default: {"modifier"=>nil, "strength"=>nil}, null: false + t.jsonb "ring2", default: {"modifier"=>nil, "strength"=>nil}, null: false + t.jsonb "ring3", default: {"modifier"=>nil, "strength"=>nil}, null: false + t.jsonb "ring4", default: {"modifier"=>nil, "strength"=>nil}, null: false + t.jsonb "earring", default: {"modifier"=>nil, "strength"=>nil}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["awakening_id"], name: "index_collection_characters_on_awakening_id" + t.index ["character_id"], name: "index_collection_characters_on_character_id" + t.index ["user_id", "character_id"], name: "index_collection_characters_on_user_id_and_character_id", unique: true + t.index ["user_id"], name: "index_collection_characters_on_user_id" + end + + create_table "collection_job_accessories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "job_accessory_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["job_accessory_id"], name: "index_collection_job_accessories_on_job_accessory_id" + t.index ["user_id", "job_accessory_id"], name: "idx_collection_job_acc_user_accessory", unique: true + t.index ["user_id"], name: "index_collection_job_accessories_on_user_id" + end + + create_table "collection_summons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "summon_id", null: false + t.integer "uncap_level", default: 0, null: false + t.integer "transcendence_step", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["summon_id"], name: "index_collection_summons_on_summon_id" + t.index ["user_id", "summon_id"], name: "index_collection_summons_on_user_id_and_summon_id" + t.index ["user_id"], name: "index_collection_summons_on_user_id" + end + + create_table "collection_weapons", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.uuid "weapon_id", null: false + t.integer "uncap_level", default: 0, null: false + t.integer "transcendence_step", default: 0 + t.uuid "weapon_key1_id" + t.uuid "weapon_key2_id" + t.uuid "weapon_key3_id" + t.uuid "weapon_key4_id" + t.uuid "awakening_id" + t.integer "awakening_level", default: 1, null: false + t.integer "ax_modifier1" + t.float "ax_strength1" + t.integer "ax_modifier2" + t.float "ax_strength2" + t.integer "element" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["awakening_id"], name: "index_collection_weapons_on_awakening_id" + t.index ["user_id", "weapon_id"], name: "index_collection_weapons_on_user_id_and_weapon_id" + t.index ["user_id"], name: "index_collection_weapons_on_user_id" + t.index ["weapon_id"], name: "index_collection_weapons_on_weapon_id" + t.index ["weapon_key1_id"], name: "index_collection_weapons_on_weapon_key1_id" + t.index ["weapon_key2_id"], name: "index_collection_weapons_on_weapon_key2_id" + t.index ["weapon_key3_id"], name: "index_collection_weapons_on_weapon_key3_id" + t.index ["weapon_key4_id"], name: "index_collection_weapons_on_weapon_key4_id" + end + create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t| end @@ -569,6 +640,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do t.integer "gender", default: 0, null: false t.string "theme", default: "system", null: false t.integer "role", default: 1, null: false + t.integer "collection_privacy", default: 0, null: false + t.index ["collection_privacy"], name: "index_users_on_collection_privacy" end create_table "weapon_awakenings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -655,6 +728,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_27_044028) do add_foreign_key "character_skills", "skills", column: "alt_skill_id" add_foreign_key "charge_attacks", "skills" add_foreign_key "charge_attacks", "skills", column: "alt_skill_id" + add_foreign_key "collection_characters", "awakenings" + add_foreign_key "collection_characters", "characters" + add_foreign_key "collection_characters", "users" + add_foreign_key "collection_job_accessories", "job_accessories" + add_foreign_key "collection_job_accessories", "users" + add_foreign_key "collection_summons", "summons" + add_foreign_key "collection_summons", "users" + add_foreign_key "collection_weapons", "awakenings" + add_foreign_key "collection_weapons", "users" + add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key1_id" + add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key2_id" + add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key3_id" + add_foreign_key "collection_weapons", "weapon_keys", column: "weapon_key4_id" + add_foreign_key "collection_weapons", "weapons" add_foreign_key "effects", "effects", column: "effect_family_id" add_foreign_key "favorites", "parties" add_foreign_key "favorites", "users" diff --git a/db/seed/canonical.rb b/db/seed/canonical.rb index db4e1b4..86d5660 100644 --- a/db/seed/canonical.rb +++ b/db/seed/canonical.rb @@ -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. diff --git a/db/seeds.rb b/db/seeds.rb index 77920bd..cf23738 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -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 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1204650 --- /dev/null +++ b/docs/README.md @@ -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 \ No newline at end of file diff --git a/docs/downloaders.md b/docs/downloaders.md new file mode 100644 index 0000000..e91e184 --- /dev/null +++ b/docs/downloaders.md @@ -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 +``` \ No newline at end of file diff --git a/docs/implementation/artifacts-feature-implementation.md b/docs/implementation/artifacts-feature-implementation.md new file mode 100644 index 0000000..88a9f43 --- /dev/null +++ b/docs/implementation/artifacts-feature-implementation.md @@ -0,0 +1,1179 @@ +# Artifacts Feature Implementation Guide + +## Overview + +This document provides step-by-step instructions for implementing the Artifacts collection tracking feature. Artifacts are character equipment that users can record in their collection and equip to characters in parties. + +## Implementation Steps + +### Phase 1: Database Setup + +#### 1.1 Create Artifacts Tables Migration + +```bash +rails generate migration CreateArtifactsSystem +``` + +```ruby +# db/migrate/xxx_create_artifacts_system.rb +class CreateArtifactsSystem < ActiveRecord::Migration[8.0] + def change + # Canonical artifact data + create_table :artifacts, id: :uuid do |t| + t.string :name_en, null: false + t.string :name_jp, null: false + t.integer :series + t.integer :weapon_specialty + t.integer :rarity, null: false # 3=R, 4=SR, 5=SSR + t.boolean :is_quirk, default: false + t.integer :max_level, null: false # 150 for standard, 200 for quirk + t.timestamps + + t.index :rarity + t.index :is_quirk + t.index :weapon_specialty + end + + # Canonical skill data + create_table :artifact_skills, id: :uuid do |t| + t.string :name_en, null: false + t.string :name_jp, null: false + t.integer :skill_group, null: false # 1=Group I, 2=Group II, 3=Group III + t.string :effect_type + t.integer :max_level, null: false, default: 15 + t.text :description_en + t.text :description_jp + t.timestamps + + t.index :skill_group + t.index :effect_type + end + end +end +``` + +#### 1.2 Create Collection Artifacts Migration + +```bash +rails generate migration CreateCollectionArtifacts +``` + +```ruby +# db/migrate/xxx_create_collection_artifacts.rb +class CreateCollectionArtifacts < ActiveRecord::Migration[8.0] + def change + create_table :collection_artifacts, id: :uuid do |t| + t.uuid :user_id, null: false + t.uuid :artifact_id, null: false + t.integer :level, null: false, default: 1 + + # Skill slots + t.uuid :skill1_id + t.integer :skill1_level, default: 1 + t.uuid :skill2_id + t.integer :skill2_level, default: 1 + t.uuid :skill3_id + t.integer :skill3_level, default: 1 + t.uuid :skill4_id # Only for quirk artifacts + t.integer :skill4_level, default: 1 + + t.timestamps + + t.index :user_id + t.index :artifact_id + t.index [:user_id, :artifact_id] + end + + add_foreign_key :collection_artifacts, :users + add_foreign_key :collection_artifacts, :artifacts + add_foreign_key :collection_artifacts, :artifact_skills, column: :skill1_id + add_foreign_key :collection_artifacts, :artifact_skills, column: :skill2_id + add_foreign_key :collection_artifacts, :artifact_skills, column: :skill3_id + add_foreign_key :collection_artifacts, :artifact_skills, column: :skill4_id + end +end +``` + +#### 1.3 Create Grid Artifacts Migration + +```bash +rails generate migration CreateGridArtifacts +``` + +```ruby +# db/migrate/xxx_create_grid_artifacts.rb +class CreateGridArtifacts < ActiveRecord::Migration[8.0] + def change + create_table :grid_artifacts, id: :uuid do |t| + t.uuid :party_id, null: false + t.uuid :grid_character_id, null: false + + # Reference to collection + t.uuid :collection_artifact_id + + # Quick-build fields (when not using collection) + t.uuid :artifact_id + t.integer :level, default: 1 + t.uuid :skill1_id + t.integer :skill1_level, default: 1 + t.uuid :skill2_id + t.integer :skill2_level, default: 1 + t.uuid :skill3_id + t.integer :skill3_level, default: 1 + t.uuid :skill4_id + t.integer :skill4_level, default: 1 + + t.timestamps + + t.index :party_id + t.index [:grid_character_id], unique: true + t.index :collection_artifact_id + t.index :artifact_id + end + + add_foreign_key :grid_artifacts, :parties + add_foreign_key :grid_artifacts, :grid_characters + add_foreign_key :grid_artifacts, :collection_artifacts + add_foreign_key :grid_artifacts, :artifacts + add_foreign_key :grid_artifacts, :artifact_skills, column: :skill1_id + add_foreign_key :grid_artifacts, :artifact_skills, column: :skill2_id + add_foreign_key :grid_artifacts, :artifact_skills, column: :skill3_id + add_foreign_key :grid_artifacts, :artifact_skills, column: :skill4_id + end +end +``` + +### Phase 2: Model Implementation + +#### 2.1 Artifact Model + +```ruby +# app/models/artifact.rb +class Artifact < ApplicationRecord + # Associations + has_many :collection_artifacts, dependent: :restrict_with_error + has_many :grid_artifacts, dependent: :restrict_with_error + + # Validations + validates :name_en, :name_jp, presence: true + validates :rarity, inclusion: { in: 3..5 } + validates :max_level, presence: true + + # Scopes + scope :standard, -> { where(is_quirk: false) } + scope :quirk, -> { where(is_quirk: true) } + scope :by_rarity, ->(rarity) { where(rarity: rarity) } + scope :by_weapon_specialty, ->(spec) { where(weapon_specialty: spec) } + + # Enums + enum weapon_specialty: { + sabre: 1, + dagger: 2, + spear: 3, + axe: 4, + staff: 5, + gun: 6, + melee: 7, + bow: 8, + harp: 9, + katana: 10 + } + + enum series: { + revans: 1, + sephira: 2, + arcarum: 3, + providence: 4 + } + + # Methods + def max_skill_slots + is_quirk ? 4 : 3 + end +end +``` + +#### 2.2 Artifact Skill Model + +```ruby +# app/models/artifact_skill.rb +class ArtifactSkill < ApplicationRecord + # Constants + GROUP_I = 1 + GROUP_II = 2 + GROUP_III = 3 + + # Validations + validates :name_en, :name_jp, presence: true + validates :skill_group, inclusion: { in: [GROUP_I, GROUP_II, GROUP_III] } + validates :max_level, presence: true + + # Scopes + scope :group_i, -> { where(skill_group: GROUP_I) } + scope :group_ii, -> { where(skill_group: GROUP_II) } + scope :group_iii, -> { where(skill_group: GROUP_III) } + + # Methods + def group_name + case skill_group + when GROUP_I then "Group I" + when GROUP_II then "Group II" + when GROUP_III then "Group III" + end + end +end +``` + +#### 2.3 Collection Artifact Model + +```ruby +# app/models/collection_artifact.rb +class CollectionArtifact < ApplicationRecord + # Associations + 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 + + # Validations + validates :level, numericality: { + greater_than_or_equal_to: 1, + less_than_or_equal_to: ->(ca) { ca.artifact&.max_level || 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 :validate_skill4_for_quirk_only + validate :validate_skill_presence + + # Scopes + scope :for_user, ->(user) { where(user: user) } + scope :by_artifact, ->(artifact_id) { where(artifact_id: artifact_id) } + + # Methods + def skills + [skill1, skill2, skill3, skill4].compact + end + + def skill_levels + { + skill1_id => skill1_level, + skill2_id => skill2_level, + skill3_id => skill3_level, + skill4_id => skill4_level + }.compact + end + + private + + def validate_skill4_for_quirk_only + if skill4_id.present? && artifact && !artifact.is_quirk + errors.add(:skill4_id, "can only be set for quirk artifacts") + end + end + + def validate_skill_presence + if artifact && artifact.is_quirk + if [skill1_id, skill2_id, skill3_id, skill4_id].any?(&:blank?) + errors.add(:base, "Quirk artifacts must have all 4 skills") + end + elsif artifact && !artifact.is_quirk + if [skill1_id, skill2_id, skill3_id].any?(&:blank?) + errors.add(:base, "Standard artifacts must have 3 skills") + end + end + end +end +``` + +#### 2.4 Grid Artifact Model + +```ruby +# app/models/grid_artifact.rb +class GridArtifact < ApplicationRecord + # Associations + belongs_to :party + belongs_to :grid_character + belongs_to :collection_artifact, optional: true + belongs_to :artifact, optional: true + 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 + + # Validations + validates :grid_character_id, uniqueness: true + validate :validate_artifact_source + validate :validate_party_ownership + + # Callbacks + before_validation :sync_from_collection, if: :from_collection? + + # Methods + 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 + + def skill_levels + if from_collection? + collection_artifact.skill_levels + else + { + skill1_id => skill1_level, + skill2_id => skill2_level, + skill3_id => skill3_level, + skill4_id => skill4_level + }.compact + end + end + + private + + def sync_from_collection + return unless collection_artifact + + self.artifact_id = collection_artifact.artifact_id + self.level = collection_artifact.level + self.skill1_id = collection_artifact.skill1_id + self.skill1_level = collection_artifact.skill1_level + self.skill2_id = collection_artifact.skill2_id + self.skill2_level = collection_artifact.skill2_level + self.skill3_id = collection_artifact.skill3_id + self.skill3_level = collection_artifact.skill3_level + self.skill4_id = collection_artifact.skill4_id + self.skill4_level = collection_artifact.skill4_level + end + + 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 + end + + def validate_party_ownership + if grid_character && grid_character.party_id != party_id + errors.add(:grid_character, "must belong to the same party") + end + end +end +``` + +#### 2.5 Model Updates + +```ruby +# app/models/user.rb (additions) +class User < ApplicationRecord + # ... existing code ... + + has_many :collection_artifacts, dependent: :destroy +end + +# app/models/grid_character.rb (additions) +class GridCharacter < ApplicationRecord + # ... existing code ... + + has_one :grid_artifact, dependent: :destroy + + def has_artifact? + grid_artifact.present? + end +end + +# app/models/party.rb (additions) +class Party < ApplicationRecord + # ... existing code ... + + has_many :grid_artifacts, dependent: :destroy +end +``` + +### Phase 3: API Implementation + +#### 3.1 Collection Artifacts Controller + +```ruby +# app/controllers/api/v1/collection/artifacts_controller.rb +module Api + module V1 + module Collection + class ArtifactsController < ApplicationController + before_action :authenticate_user! + before_action :set_collection_artifact, only: [:show, :update, :destroy] + + # GET /api/v1/collection/artifacts + def index + artifacts = current_user.collection_artifacts + .includes(:artifact, :skill1, :skill2, :skill3, :skill4) + + artifacts = artifacts.by_artifact(params[:artifact_id]) if params[:artifact_id].present? + + render json: CollectionArtifactBlueprint.render( + artifacts.page(params[:page]).per(params[:per_page] || 50), + root: :collection_artifacts, + meta: pagination_meta(artifacts) + ) + end + + # GET /api/v1/collection/artifacts/:id + def show + render json: CollectionArtifactBlueprint.render( + @collection_artifact, + root: :collection_artifact, + view: :extended + ) + end + + # POST /api/v1/collection/artifacts + def create + @collection_artifact = current_user.collection_artifacts.build(collection_artifact_params) + + if @collection_artifact.save + render json: CollectionArtifactBlueprint.render( + @collection_artifact, + root: :collection_artifact + ), status: :created + else + render json: { errors: @collection_artifact.errors }, status: :unprocessable_entity + end + end + + # PUT /api/v1/collection/artifacts/:id + def update + if @collection_artifact.update(collection_artifact_params) + render json: CollectionArtifactBlueprint.render( + @collection_artifact, + root: :collection_artifact + ) + else + render json: { errors: @collection_artifact.errors }, status: :unprocessable_entity + end + end + + # DELETE /api/v1/collection/artifacts/:id + def destroy + if @collection_artifact.destroy + head :no_content + else + render json: { errors: @collection_artifact.errors }, status: :unprocessable_entity + end + end + + # GET /api/v1/collection/statistics + def statistics + stats = { + total_artifacts: current_user.collection_artifacts.count, + breakdown_by_rarity: current_user.collection_artifacts + .joins(:artifact) + .group('artifacts.rarity') + .count, + breakdown_by_level: current_user.collection_artifacts + .group(:level) + .count + } + + render json: stats + end + + private + + def set_collection_artifact + @collection_artifact = current_user.collection_artifacts.find(params[:id]) + end + + def collection_artifact_params + params.require(:collection_artifact).permit( + :artifact_id, :level, + :skill1_id, :skill1_level, + :skill2_id, :skill2_level, + :skill3_id, :skill3_level, + :skill4_id, :skill4_level + ) + end + end + end + end +end +``` + +#### 3.2 Grid Artifacts Controller + +```ruby +# app/controllers/api/v1/grid_artifacts_controller.rb +module Api + module V1 + class GridArtifactsController < ApplicationController + before_action :authenticate_user! + before_action :set_party + before_action :authorize_party_edit! + before_action :set_grid_artifact, only: [:show, :update, :destroy] + + # GET /api/v1/parties/:party_id/grid_artifacts + def index + artifacts = @party.grid_artifacts + .includes(:grid_character, :artifact, :collection_artifact, + :skill1, :skill2, :skill3, :skill4) + + render json: GridArtifactBlueprint.render( + artifacts, + root: :grid_artifacts + ) + end + + # GET /api/v1/parties/:party_id/grid_artifacts/:id + def show + render json: GridArtifactBlueprint.render( + @grid_artifact, + root: :grid_artifact, + view: :extended + ) + end + + # POST /api/v1/parties/:party_id/grid_artifacts + def create + @grid_artifact = @party.grid_artifacts.build(grid_artifact_params) + + if @grid_artifact.save + render json: GridArtifactBlueprint.render( + @grid_artifact, + root: :grid_artifact + ), status: :created + else + render json: { errors: @grid_artifact.errors }, status: :unprocessable_entity + end + end + + # PUT /api/v1/parties/:party_id/grid_artifacts/:id + def update + if @grid_artifact.update(grid_artifact_params) + render json: GridArtifactBlueprint.render( + @grid_artifact, + root: :grid_artifact + ) + else + render json: { errors: @grid_artifact.errors }, status: :unprocessable_entity + end + end + + # DELETE /api/v1/parties/:party_id/grid_artifacts/:id + def destroy + if @grid_artifact.destroy + head :no_content + else + render json: { errors: @grid_artifact.errors }, status: :unprocessable_entity + end + end + + private + + def set_party + @party = current_user.parties.find(params[:party_id]) + end + + def set_grid_artifact + @grid_artifact = @party.grid_artifacts.find(params[:id]) + end + + def authorize_party_edit! + unless @party.user == current_user + render json: { error: "Not authorized" }, status: :forbidden + end + end + + def grid_artifact_params + params.require(:grid_artifact).permit( + :grid_character_id, :collection_artifact_id, :artifact_id, :level, + :skill1_id, :skill1_level, + :skill2_id, :skill2_level, + :skill3_id, :skill3_level, + :skill4_id, :skill4_level + ) + end + end + end +end +``` + +#### 3.3 Artifacts Controller (Canonical Data) + +```ruby +# app/controllers/api/v1/artifacts_controller.rb +module Api + module V1 + class ArtifactsController < ApplicationController + before_action :set_artifact, only: [:show] + + # GET /api/v1/artifacts + def index + artifacts = Artifact.all + artifacts = artifacts.quirk if params[:is_quirk] == 'true' + artifacts = artifacts.standard if params[:is_quirk] == 'false' + artifacts = artifacts.by_weapon_specialty(params[:weapon_specialty]) if params[:weapon_specialty].present? + + render json: ArtifactBlueprint.render( + artifacts.page(params[:page]).per(params[:per_page] || 50), + root: :artifacts, + meta: pagination_meta(artifacts) + ) + end + + # GET /api/v1/artifacts/:id + def show + render json: ArtifactBlueprint.render( + @artifact, + root: :artifact + ) + end + + private + + def set_artifact + @artifact = Artifact.find(params[:id]) + end + end + end +end + +# app/controllers/api/v1/artifact_skills_controller.rb +module Api + module V1 + class ArtifactSkillsController < ApplicationController + before_action :set_artifact_skill, only: [:show] + + # GET /api/v1/artifact_skills + def index + skills = ArtifactSkill.all + skills = skills.where(skill_group: params[:skill_group]) if params[:skill_group].present? + + render json: ArtifactSkillBlueprint.render( + skills.page(params[:page]).per(params[:per_page] || 50), + root: :artifact_skills, + meta: pagination_meta(skills) + ) + end + + # GET /api/v1/artifact_skills/:id + def show + render json: ArtifactSkillBlueprint.render( + @artifact_skill, + root: :artifact_skill + ) + end + + private + + def set_artifact_skill + @artifact_skill = ArtifactSkill.find(params[:id]) + end + end + end +end +``` + +#### 3.4 User Collections Controller + +```ruby +# app/controllers/api/v1/users/collection/artifacts_controller.rb +module Api + module V1 + module Users + module Collection + class ArtifactsController < ApplicationController + before_action :set_user + + # GET /api/v1/users/:user_id/collection/artifacts + def index + unless can_view_collection?(@user) + render json: { error: "You do not have permission to view this collection" }, + status: :forbidden + return + end + + artifacts = @user.collection_artifacts + .includes(:artifact, :skill1, :skill2, :skill3, :skill4) + + render json: CollectionArtifactBlueprint.render( + artifacts.page(params[:page]).per(params[:per_page] || 50), + root: :collection_artifacts, + meta: pagination_meta(artifacts) + ) + end + + private + + def set_user + @user = User.find(params[:user_id]) + end + + def can_view_collection?(user) + case user.collection_privacy + when 'public' + true + when 'crew_only' + # Check if viewer is in same crew (when crew feature is implemented) + current_user && current_user.crew_id == user.crew_id + when 'private' + current_user && current_user.id == user.id + else + false + end + end + end + end + end + end +end +``` + +### Phase 4: Blueprints + +```ruby +# app/blueprints/artifact_blueprint.rb +class ArtifactBlueprint < ApplicationBlueprint + identifier :id + + fields :name_en, :name_jp, :series, :weapon_specialty, :rarity, :is_quirk, :max_level + + field :max_skill_slots do |artifact| + artifact.max_skill_slots + end +end + +# app/blueprints/artifact_skill_blueprint.rb +class ArtifactSkillBlueprint < ApplicationBlueprint + identifier :id + + fields :name_en, :name_jp, :skill_group, :effect_type, :max_level, + :description_en, :description_jp + + field :group_name do |skill| + skill.group_name + end +end + +# app/blueprints/collection_artifact_blueprint.rb +class CollectionArtifactBlueprint < ApplicationBlueprint + identifier :id + + fields :level, :created_at, :updated_at + + association :artifact, blueprint: ArtifactBlueprint + + field :skills do |ca| + skills = [] + + if ca.skill1 + skills << { + slot: 1, + skill: ArtifactSkillBlueprint.render_as_hash(ca.skill1), + level: ca.skill1_level + } + end + + if ca.skill2 + skills << { + slot: 2, + skill: ArtifactSkillBlueprint.render_as_hash(ca.skill2), + level: ca.skill2_level + } + end + + if ca.skill3 + skills << { + slot: 3, + skill: ArtifactSkillBlueprint.render_as_hash(ca.skill3), + level: ca.skill3_level + } + end + + if ca.skill4 + skills << { + slot: 4, + skill: ArtifactSkillBlueprint.render_as_hash(ca.skill4), + level: ca.skill4_level + } + end + + skills + end + + view :extended do + field :equipped_in_parties do |ca| + ca.grid_artifact&.party_id + end + end +end + +# app/blueprints/grid_artifact_blueprint.rb +class GridArtifactBlueprint < ApplicationBlueprint + identifier :id + + field :from_collection do |ga| + ga.from_collection? + end + + field :level do |ga| + ga.from_collection? ? ga.collection_artifact.level : ga.level + end + + association :grid_character, blueprint: GridCharacterBlueprint + + field :artifact do |ga| + ArtifactBlueprint.render_as_hash(ga.artifact_details) + end + + field :skills do |ga| + skills = [] + skill_levels = ga.skill_levels + + ga.skills.each_with_index do |skill, index| + next unless skill + skills << { + slot: index + 1, + skill: ArtifactSkillBlueprint.render_as_hash(skill), + level: skill_levels[skill.id] || 1 + } + end + + skills + end + + view :extended do + association :collection_artifact, blueprint: CollectionArtifactBlueprint, + if: ->(ga) { ga.from_collection? } + end +end +``` + +### Phase 5: Routes Configuration + +```ruby +# config/routes.rb (additions) +Rails.application.routes.draw do + namespace :api do + namespace :v1 do + # Canonical artifact data + resources :artifacts, only: [:index, :show] + resources :artifact_skills, only: [:index, :show] + + # User collection + namespace :collection do + resources :artifacts do + collection do + get 'statistics' + end + end + end + + # Grid artifacts (nested under parties) + resources :parties do + resources :grid_artifacts + end + + # View other users' collections + resources :users, only: [] do + namespace :collection do + resources :artifacts, only: [:index] + end + end + end + end +end +``` + +### Phase 6: Seed Data + +```ruby +# db/seeds/artifacts.rb + +# Create artifact skills +puts "Creating artifact skills..." + +# Group I Skills +group_i_skills = [ + { name_en: "ATK Up", name_jp: "攻撃力アップ", skill_group: 1, effect_type: "atk", max_level: 15 }, + { name_en: "HP Up", name_jp: "HPアップ", skill_group: 1, effect_type: "hp", max_level: 15 }, + { name_en: "Critical Hit Rate", name_jp: "クリティカル確率", skill_group: 1, effect_type: "crit", max_level: 15 }, + { name_en: "Double Attack Rate", name_jp: "連続攻撃確率", skill_group: 1, effect_type: "da", max_level: 15 }, + { name_en: "Triple Attack Rate", name_jp: "トリプルアタック確率", skill_group: 1, effect_type: "ta", max_level: 15 } +] + +# Group II Skills +group_ii_skills = [ + { name_en: "Enmity", name_jp: "背水", skill_group: 2, effect_type: "enmity", max_level: 10 }, + { name_en: "Stamina", name_jp: "渾身", skill_group: 2, effect_type: "stamina", max_level: 10 }, + { name_en: "Charge Bar Gain", name_jp: "奥義ゲージ上昇量", skill_group: 2, effect_type: "charge", max_level: 10 } +] + +# Group III Skills +group_iii_skills = [ + { name_en: "Skill DMG Cap Up", name_jp: "アビリティダメージ上限", skill_group: 3, effect_type: "skill_cap", max_level: 5 }, + { name_en: "C.A. DMG Cap Up", name_jp: "奥義ダメージ上限", skill_group: 3, effect_type: "ca_cap", max_level: 5 }, + { name_en: "Normal Attack Cap Up", name_jp: "通常攻撃上限", skill_group: 3, effect_type: "auto_cap", max_level: 5 } +] + +(group_i_skills + group_ii_skills + group_iii_skills).each do |skill_data| + ArtifactSkill.find_or_create_by!( + name_en: skill_data[:name_en] + ) do |skill| + skill.assign_attributes(skill_data) + end +end + +# Create artifacts +puts "Creating artifacts..." + +standard_artifacts = [ + { name_en: "Revans Gauntlet", name_jp: "レヴァンスガントレット", series: 1, weapon_specialty: 7, rarity: 5, is_quirk: false, max_level: 150 }, + { name_en: "Revans Armor", name_jp: "レヴァンスアーマー", series: 1, weapon_specialty: 1, rarity: 5, is_quirk: false, max_level: 150 }, + { name_en: "Sephira Ring", name_jp: "セフィラリング", series: 2, weapon_specialty: 5, rarity: 5, is_quirk: false, max_level: 150 }, + { name_en: "Arcarum Card", name_jp: "アーカルムカード", series: 3, weapon_specialty: 2, rarity: 4, is_quirk: false, max_level: 150 } +] + +quirk_artifacts = [ + { name_en: "Quirk: Crimson Finger", name_jp: "絆器:クリムゾンフィンガー", series: 4, weapon_specialty: 6, rarity: 5, is_quirk: true, max_level: 200 }, + { name_en: "Quirk: Blue Sphere", name_jp: "絆器:ブルースフィア", series: 4, weapon_specialty: 5, rarity: 5, is_quirk: true, max_level: 200 } +] + +(standard_artifacts + quirk_artifacts).each do |artifact_data| + Artifact.find_or_create_by!( + name_en: artifact_data[:name_en] + ) do |artifact| + artifact.assign_attributes(artifact_data) + end +end + +puts "Seeded #{ArtifactSkill.count} artifact skills and #{Artifact.count} artifacts" +``` + +### Phase 7: Testing + +#### 7.1 Model Specs + +```ruby +# spec/models/artifact_spec.rb +require 'rails_helper' + +RSpec.describe Artifact, type: :model do + describe 'validations' do + it { should validate_presence_of(:name_en) } + it { should validate_presence_of(:name_jp) } + it { should validate_inclusion_of(:rarity).in_array([3, 4, 5]) } + end + + describe 'associations' do + it { should have_many(:collection_artifacts) } + it { should have_many(:grid_artifacts) } + end + + describe '#max_skill_slots' do + it 'returns 3 for standard artifacts' do + artifact = build(:artifact, is_quirk: false) + expect(artifact.max_skill_slots).to eq(3) + end + + it 'returns 4 for quirk artifacts' do + artifact = build(:artifact, is_quirk: true) + expect(artifact.max_skill_slots).to eq(4) + end + end +end + +# spec/models/collection_artifact_spec.rb +require 'rails_helper' + +RSpec.describe CollectionArtifact, type: :model do + let(:user) { create(:user) } + let(:standard_artifact) { create(:artifact, is_quirk: false) } + let(:quirk_artifact) { create(:artifact, is_quirk: true) } + + describe 'validations' do + it 'validates level is within artifact max level' do + ca = build(:collection_artifact, artifact: standard_artifact, level: 151) + expect(ca).not_to be_valid + expect(ca.errors[:level]).to be_present + end + + it 'prevents skill4 on standard artifacts' do + ca = build(:collection_artifact, + artifact: standard_artifact, + skill4: create(:artifact_skill) + ) + expect(ca).not_to be_valid + expect(ca.errors[:skill4_id]).to include("can only be set for quirk artifacts") + end + + it 'allows skill4 on quirk artifacts' do + ca = build(:collection_artifact, + artifact: quirk_artifact, + skill1: create(:artifact_skill, skill_group: 1), + skill2: create(:artifact_skill, skill_group: 1), + skill3: create(:artifact_skill, skill_group: 2), + skill4: create(:artifact_skill, skill_group: 3) + ) + expect(ca).to be_valid + end + end +end + +# spec/models/grid_artifact_spec.rb +require 'rails_helper' + +RSpec.describe GridArtifact, type: :model do + let(:party) { create(:party) } + let(:grid_character) { create(:grid_character, party: party) } + let(:collection_artifact) { create(:collection_artifact) } + + describe 'validations' do + it 'ensures one artifact per character' do + create(:grid_artifact, party: party, grid_character: grid_character) + duplicate = build(:grid_artifact, party: party, grid_character: grid_character) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:grid_character_id]).to be_present + end + + it 'requires either collection or quick-build artifact' do + ga = build(:grid_artifact, party: party, grid_character: grid_character) + expect(ga).not_to be_valid + expect(ga.errors[:base]).to include("Must specify either collection artifact or quick-build artifact") + end + end + + describe '#from_collection?' do + it 'returns true when using collection artifact' do + ga = build(:grid_artifact, collection_artifact: collection_artifact) + expect(ga.from_collection?).to be true + end + + it 'returns false when quick-building' do + ga = build(:grid_artifact, artifact: create(:artifact)) + expect(ga.from_collection?).to be false + end + end +end +``` + +#### 7.2 Controller Specs + +```ruby +# spec/controllers/api/v1/collection/artifacts_controller_spec.rb +require 'rails_helper' + +RSpec.describe Api::V1::Collection::ArtifactsController, type: :controller do + let(:user) { create(:user) } + let(:artifact) { create(:artifact) } + let(:skill1) { create(:artifact_skill, skill_group: 1) } + let(:skill2) { create(:artifact_skill, skill_group: 1) } + let(:skill3) { create(:artifact_skill, skill_group: 2) } + + before { sign_in user } + + describe 'GET #index' do + let!(:collection_artifacts) { create_list(:collection_artifact, 3, user: user) } + + it 'returns user collection artifacts' do + get :index + expect(response).to have_http_status(:success) + json = JSON.parse(response.body) + expect(json['collection_artifacts'].size).to eq(3) + end + end + + describe 'POST #create' do + let(:valid_params) do + { + collection_artifact: { + artifact_id: artifact.id, + level: 50, + skill1_id: skill1.id, + skill1_level: 5, + skill2_id: skill2.id, + skill2_level: 3, + skill3_id: skill3.id, + skill3_level: 2 + } + } + end + + it 'creates a new collection artifact' do + expect { + post :create, params: valid_params + }.to change(CollectionArtifact, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + describe 'DELETE #destroy' do + let!(:collection_artifact) { create(:collection_artifact, user: user) } + + it 'deletes the artifact' do + expect { + delete :destroy, params: { id: collection_artifact.id } + }.to change(CollectionArtifact, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + end +end +``` + +## Deployment Checklist + +### Pre-deployment +- [ ] Run all migrations in development +- [ ] Seed artifact and skill data +- [ ] Test all CRUD operations +- [ ] Verify privacy controls work correctly + +### Deployment +1. Deploy database migrations +2. Run seed data for artifacts and skills +3. Deploy application code +4. Verify artifact endpoints +5. Test collection and grid functionality + +### Post-deployment +- [ ] Monitor error rates +- [ ] Check database performance +- [ ] Verify user collections are accessible +- [ ] Test party integration + +## Performance Considerations + +1. **Database Indexes**: All foreign keys and common query patterns are indexed +2. **Eager Loading**: Use includes() to prevent N+1 queries +3. **Pagination**: All list endpoints support pagination + +## Security Notes + +1. **Authorization**: Users can only modify their own collection +2. **Privacy**: Collection viewing respects user privacy settings +3. **Validation**: Strict validation at model level +4. **Party Ownership**: Only party owners can modify grid artifacts \ No newline at end of file diff --git a/docs/implementation/collection-tracking-implementation.md b/docs/implementation/collection-tracking-implementation.md new file mode 100644 index 0000000..5017d89 --- /dev/null +++ b/docs/implementation/collection-tracking-implementation.md @@ -0,0 +1,1043 @@ +# Collection Tracking Implementation Guide + +## Prerequisites + +- Rails 8.0.1 environment set up +- PostgreSQL database running +- Basic understanding of the existing codebase structure +- Familiarity with Rails migrations, models, and controllers + +## Step-by-Step Implementation + +### Step 1: Create Database Migrations + +#### 1.0 Add collection privacy levels to Users table + +```bash +rails generate migration AddCollectionPrivacyToUsers +``` + +```ruby +# db/migrate/xxx_add_collection_privacy_to_users.rb +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 +``` + +#### 1.1 Create CollectionCharacters migration + +```bash +rails generate migration CreateCollectionCharacters +``` + +```ruby +# db/migrate/xxx_create_collection_characters.rb +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 + add_index :collection_characters, :user_id + add_index :collection_characters, :character_id + end +end +``` + +#### 1.2 Create CollectionWeapons migration + +```bash +rails generate migration CreateCollectionWeapons +``` + +```ruby +# db/migrate/xxx_create_collection_weapons.rb +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.string :weapon_key4_id + + 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 + add_index :collection_weapons, :weapon_id + add_index :collection_weapons, [:user_id, :weapon_id] + end +end +``` + +#### 1.3 Create CollectionSummons migration + +```bash +rails generate migration CreateCollectionSummons +``` + +```ruby +# db/migrate/xxx_create_collection_summons.rb +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 + add_index :collection_summons, :summon_id + add_index :collection_summons, [:user_id, :summon_id] + end +end +``` + +#### 1.4 Create CollectionJobAccessories migration + +```bash +rails generate migration CreateCollectionJobAccessories +``` + +```ruby +# db/migrate/xxx_create_collection_job_accessories.rb +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' + add_index :collection_job_accessories, :user_id + add_index :collection_job_accessories, :job_accessory_id + end +end +``` + +#### 1.5 Run migrations + +```bash +rails db:migrate +``` + +### Step 2: Create Models + +#### 2.1 Create CollectionCharacter model + +```ruby +# app/models/collection_character.rb +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 + + 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 + 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 +end +``` + +#### 2.2 Create CollectionWeapon model + +```ruby +# app/models/collection_weapon.rb +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 }, allow_nil: true + validates :awakening_level, inclusion: { in: 1..10 } + + validate :validate_weapon_keys + validate :validate_ax_skills + validate :validate_element_change + validate :validate_awakening_compatibility + + 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) } + + def blueprint + CollectionWeaponBlueprint + end + + def weapon_keys + [weapon_key1, weapon_key2, weapon_key3, weapon_key4].compact + end + + private + + def validate_weapon_keys + return unless weapon.present? + + 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 + return unless weapon.present? && weapon.ax + + if (ax_modifier1.present? && ax_strength1.blank?) || + (ax_modifier1.blank? && ax_strength1.present?) + errors.add(:ax_modifier1, "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(:ax_modifier2, "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, "cannot be changed for this weapon series") + 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 +end +``` + +#### 2.3 Create CollectionSummon model + +```ruby +# app/models/collection_summon.rb +class CollectionSummon < ApplicationRecord + belongs_to :user + belongs_to :summon + + validates :uncap_level, inclusion: { in: 0..5 } + validates :transcendence_step, inclusion: { in: 0..10 } + + scope :by_summon, ->(summon_id) { where(summon_id: summon_id) } + scope :by_element, ->(element) { joins(:summon).where(summons: { element: element }) } + scope :transcended, -> { where('transcendence_step > 0') } + + def blueprint + CollectionSummonBlueprint + end +end +``` + +#### 2.4 Create CollectionJobAccessory model + +```ruby +# app/models/collection_job_accessory.rb +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 }) } + + def blueprint + CollectionJobAccessoryBlueprint + end +end +``` + +#### 2.5 Update User model + +```ruby +# app/models/user.rb - Add these associations and methods + +# Associations +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 + +# Enum for collection privacy levels +enum collection_privacy: { + public: 0, + crew_only: 1, + private: 2 +} + +# Add collection statistics method +def collection_statistics + { + total_characters: collection_characters.count, + total_weapons: collection_weapons.count, + total_summons: collection_summons.count, + total_job_accessories: collection_job_accessories.count, + unique_weapons: collection_weapons.distinct.count(:weapon_id), + unique_summons: collection_summons.distinct.count(:summon_id) + } +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 'public' + 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' + 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 +``` + +### Step 3: Create Blueprints + +#### 3.1 CollectionCharacterBlueprint + +```ruby +# app/blueprints/api/v1/collection_character_blueprint.rb +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: :nested + + view :full do + association :character, blueprint: CharacterBlueprint, view: :full + end + end + end +end +``` + +#### 3.2 CollectionWeaponBlueprint + +```ruby +# app/blueprints/api/v1/collection_weapon_blueprint.rb +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, view: :nested + association :weapon_keys, blueprint: WeaponKeyBlueprint, + if: ->(_, obj, _) { obj.weapon_keys.any? } + + view :full do + association :weapon, blueprint: WeaponBlueprint, view: :full + end + end + end +end +``` + +#### 3.3 CollectionSummonBlueprint + +```ruby +# app/blueprints/api/v1/collection_summon_blueprint.rb +module Api + module V1 + class CollectionSummonBlueprint < ApiBlueprint + identifier :id + + fields :uncap_level, :transcendence_step, + :created_at, :updated_at + + association :summon, blueprint: SummonBlueprint, view: :nested + + view :full do + association :summon, blueprint: SummonBlueprint, view: :full + end + end + end +end +``` + +#### 3.4 CollectionJobAccessoryBlueprint + +```ruby +# app/blueprints/api/v1/collection_job_accessory_blueprint.rb +module Api + module V1 + class CollectionJobAccessoryBlueprint < ApiBlueprint + identifier :id + + fields :created_at, :updated_at + + association :job_accessory, blueprint: JobAccessoryBlueprint + end + end +end +``` + +### Step 4: Create Controllers + +#### 4.1 Base Collection Controller (with User Collection Viewing and Privacy) + +```ruby +# app/controllers/api/v1/collection_controller.rb +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: CollectionCharacterBlueprint.render_as_hash( + @user.collection_characters.includes(:character, :awakening), + view: :full + ) + } + when 'weapons' + { + weapons: CollectionWeaponBlueprint.render_as_hash( + @user.collection_weapons.includes(:weapon, :awakening, :weapon_key1, + :weapon_key2, :weapon_key3, :weapon_key4), + view: :full + ) + } + when 'summons' + { + summons: CollectionSummonBlueprint.render_as_hash( + @user.collection_summons.includes(:summon), + view: :full + ) + } + when 'job_accessories' + { + job_accessories: CollectionJobAccessoryBlueprint.render_as_hash( + @user.collection_job_accessories.includes(job_accessory: :job) + ) + } + else + # Return complete collection + { + characters: CollectionCharacterBlueprint.render_as_hash( + @user.collection_characters.includes(:character, :awakening), + view: :full + ), + weapons: CollectionWeaponBlueprint.render_as_hash( + @user.collection_weapons.includes(:weapon, :awakening, :weapon_key1, + :weapon_key2, :weapon_key3, :weapon_key4), + view: :full + ), + summons: CollectionSummonBlueprint.render_as_hash( + @user.collection_summons.includes(:summon), + view: :full + ), + job_accessories: CollectionJobAccessoryBlueprint.render_as_hash( + @user.collection_job_accessories.includes(job_accessory: :job) + ) + } + end + + render json: collection + end + + def statistics + stats = @user.collection_statistics + render json: stats + 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 +``` + +#### 4.2 CollectionCharactersController + +```ruby +# app/controllers/api/v1/collection_characters_controller.rb +module Api + module V1 + class CollectionCharactersController < ApiController + before_action :authenticate_user! + before_action :set_collection_character, only: [:show, :update, :destroy] + + def index + @collection_characters = current_user.collection_characters + .includes(:character, :awakening) + .page(params[:page]) + .per(params[:limit] || 50) + + render json: CollectionCharacterBlueprint.render( + @collection_characters, + root: :collection_characters, + meta: pagination_meta(@collection_characters) + ) + end + + def show + render json: 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: CollectionCharacterBlueprint.render( + @collection_character, + view: :full + ), status: :created + else + render_errors(@collection_character.errors) + end + end + + def update + if @collection_character.update(collection_character_params) + render json: CollectionCharacterBlueprint.render( + @collection_character, + view: :full + ) + else + render_errors(@collection_character.errors) + 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 + render json: { error: "Collection character not found" }, status: :not_found + end + + def collection_character_params + params.require(:collection_character).permit( + :character_id, :uncap_level, :transcendence_step, :perpetuity, + :awakening_id, :awakening_level, + ring1: [:modifier, :strength], + ring2: [:modifier, :strength], + ring3: [:modifier, :strength], + ring4: [:modifier, :strength], + earring: [:modifier, :strength] + ) + end + end + end +end +``` + +#### 4.3 CollectionWeaponsController + +```ruby +# app/controllers/api/v1/collection_weapons_controller.rb +module Api + module V1 + class CollectionWeaponsController < ApiController + before_action :authenticate_user! + 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.page(params[:page]).per(params[:limit] || 50) + + render json: CollectionWeaponBlueprint.render( + @collection_weapons, + root: :collection_weapons, + meta: pagination_meta(@collection_weapons) + ) + end + + def show + render json: 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: CollectionWeaponBlueprint.render( + @collection_weapon, + view: :full + ), status: :created + else + render_errors(@collection_weapon.errors) + end + end + + def update + if @collection_weapon.update(collection_weapon_params) + render json: CollectionWeaponBlueprint.render( + @collection_weapon, + view: :full + ) + else + render_errors(@collection_weapon.errors) + 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 + render json: { error: "Collection weapon not found" }, status: :not_found + 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 +``` + +#### 4.4 CollectionSummonsController + +```ruby +# app/controllers/api/v1/collection_summons_controller.rb +module Api + module V1 + class CollectionSummonsController < ApiController + before_action :authenticate_user! + before_action :set_collection_summon, only: [: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.page(params[:page]).per(params[:limit] || 50) + + render json: CollectionSummonBlueprint.render( + @collection_summons, + root: :collection_summons, + meta: pagination_meta(@collection_summons) + ) + end + + def show + render json: 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: CollectionSummonBlueprint.render( + @collection_summon, + view: :full + ), status: :created + else + render_errors(@collection_summon.errors) + end + end + + def update + if @collection_summon.update(collection_summon_params) + render json: CollectionSummonBlueprint.render( + @collection_summon, + view: :full + ) + else + render_errors(@collection_summon.errors) + 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 + render json: { error: "Collection summon not found" }, status: :not_found + end + + def collection_summon_params + params.require(:collection_summon).permit( + :summon_id, :uncap_level, :transcendence_step + ) + end + end + end +end +``` + +#### 4.5 CollectionJobAccessoriesController + +```ruby +# app/controllers/api/v1/collection_job_accessories_controller.rb +module Api + module V1 + class CollectionJobAccessoriesController < ApiController + before_action :authenticate_user! + before_action :set_collection_job_accessory, only: [:destroy] + + def index + @collection_accessories = current_user.collection_job_accessories + .includes(job_accessory: :job) + + if params[:job_id] + @collection_accessories = @collection_accessories.by_job(params[:job_id]) + end + + @collection_accessories = @collection_accessories.page(params[:page]) + .per(params[:limit] || 50) + + render json: CollectionJobAccessoryBlueprint.render( + @collection_accessories, + root: :collection_job_accessories, + meta: pagination_meta(@collection_accessories) + ) + end + + def create + @collection_accessory = current_user.collection_job_accessories + .build(collection_job_accessory_params) + + if @collection_accessory.save + render json: CollectionJobAccessoryBlueprint.render( + @collection_accessory + ), status: :created + else + render_errors(@collection_accessory.errors) + 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 + render json: { error: "Collection job accessory not found" }, status: :not_found + end + + def collection_job_accessory_params + params.require(:collection_job_accessory).permit(:job_accessory_id) + end + end + end +end +``` + +### Step 5: Update Routes + +```ruby +# config/routes.rb - Add these routes within the API scope + +# User collection viewing (respects privacy settings) +get 'users/:user_id/collection', to: 'collection#show' +get 'users/:user_id/collection/statistics', to: 'collection#statistics' + +# 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, :create, :destroy] +end +``` + +### Step 6: Add Helper Methods to ApiController + +```ruby +# app/controllers/api/v1/api_controller.rb - Add these helper methods + +protected + +def pagination_meta(collection) + { + current_page: collection.current_page, + total_pages: collection.total_pages, + total_count: collection.total_count, + per_page: collection.limit_value + } +end + +def render_errors(errors, status = :unprocessable_entity) + render json: { errors: errors.full_messages }, status: status +end +``` + +## Testing the Implementation + +### Manual Testing Steps + +1. **Start Rails server** + ```bash + rails server + ``` + +2. **View a user's complete collection** + ```bash + # Get complete collection + curl -X GET http://localhost:3000/api/v1/users/USER_ID/collection + + # Get only weapons + curl -X GET http://localhost:3000/api/v1/users/USER_ID/collection?type=weapons + + # Get only characters + curl -X GET http://localhost:3000/api/v1/users/USER_ID/collection?type=characters + + # Get only summons + curl -X GET http://localhost:3000/api/v1/users/USER_ID/collection?type=summons + + # Get only job accessories + curl -X GET http://localhost:3000/api/v1/users/USER_ID/collection?type=job_accessories + ``` + +3. **Get collection statistics** + ```bash + curl -X GET http://localhost:3000/api/v1/users/USER_ID/collection/statistics + ``` + +4. **Create collection items (authenticated)** + ```bash + # Create a character + curl -X POST http://localhost:3000/api/v1/collection/characters \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"collection_character": {"character_id": "uuid", "uncap_level": 3}}' + + # Create a weapon + curl -X POST http://localhost:3000/api/v1/collection/weapons \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"collection_weapon": {"weapon_id": "uuid", "uncap_level": 4}}' + ``` + +## Deployment Checklist + +- [ ] Run all migrations in staging +- [ ] Test all endpoints in staging +- [ ] Verify database indexes are created +- [ ] Test with large datasets +- [ ] Set up error tracking (Sentry/Rollbar) +- [ ] Create backup before deployment +- [ ] Prepare rollback plan +- [ ] Update API documentation +- [ ] Notify frontend team of new endpoints +- [ ] Schedule deployment during low-traffic window +- [ ] Monitor application after deployment + +## API Endpoint Summary + +### Public Collection Viewing (Respects Privacy Settings) +- `GET /api/v1/users/:user_id/collection` - View complete collection (if not private) +- `GET /api/v1/users/:user_id/collection?type=characters` - View characters only (if not private) +- `GET /api/v1/users/:user_id/collection?type=weapons` - View weapons only (if not private) +- `GET /api/v1/users/:user_id/collection?type=summons` - View summons only (if not private) +- `GET /api/v1/users/:user_id/collection?type=job_accessories` - View job accessories only (if not private) +- `GET /api/v1/users/:user_id/collection/statistics` - View collection statistics (if not private) + +### Collection Management (Authentication Required) +- `GET/POST/PUT/DELETE /api/v1/collection/characters` - Manage character collection +- `GET/POST/PUT/DELETE /api/v1/collection/weapons` - Manage weapon collection +- `GET/POST/PUT/DELETE /api/v1/collection/summons` - Manage summon collection +- `GET/POST/DELETE /api/v1/collection/job_accessories` - Manage job accessory collection + +### Privacy Settings (Authentication Required) +To update collection privacy settings, use the existing user update endpoint: +- `PUT /api/v1/users/:id` - Update user settings including `collection_privacy` field + +Privacy levels: +- `0` or `"public"`: Collection is viewable by everyone +- `1` or `"crew_only"`: Collection is viewable only by crew members (when crew feature is implemented) +- `2` or `"private"`: Collection is viewable only by the owner + +Example request: +```json +{ + "user": { + "collection_privacy": "crew_only" + } +} +``` \ No newline at end of file diff --git a/docs/implementation/crew-feature-implementation.md b/docs/implementation/crew-feature-implementation.md new file mode 100644 index 0000000..e4ee12f --- /dev/null +++ b/docs/implementation/crew-feature-implementation.md @@ -0,0 +1,1466 @@ +# Crew Feature Implementation Guide + +## Prerequisites + +- Rails 8.0.1 environment +- PostgreSQL database +- Existing user authentication system +- Collection tracking feature implemented (for privacy integration) + +## Step-by-Step Implementation + +### Step 1: Database Migrations + +#### 1.1 Create Crews table + +```bash +rails generate migration CreateCrews +``` + +```ruby +# db/migrate/xxx_create_crews.rb +class CreateCrews < ActiveRecord::Migration[8.0] + def change + create_table :crews, id: :uuid do |t| + t.string :name, null: false + t.references :captain, type: :uuid, null: false, foreign_key: { to_table: :users } + t.string :gamertag, limit: 4 + t.text :rules + t.integer :member_count, default: 1, null: false + + t.timestamps + end + + add_index :crews, :name, unique: true + add_index :crews, :gamertag, unique: true, where: "gamertag IS NOT NULL" + add_index :crews, :created_at + end +end +``` + +#### 1.2 Create CrewMemberships table + +```bash +rails generate migration CreateCrewMemberships +``` + +```ruby +# db/migrate/xxx_create_crew_memberships.rb +class CreateCrewMemberships < ActiveRecord::Migration[8.0] + def change + create_table :crew_memberships, id: :uuid do |t| + t.references :crew, type: :uuid, null: false, foreign_key: true + t.references :user, type: :uuid, null: false, foreign_key: true + t.integer :role, default: 0, null: false # 0=member, 1=subcaptain, 2=captain + t.boolean :display_gamertag, default: true, null: false + t.datetime :joined_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false + + t.timestamps + end + + add_index :crew_memberships, [:crew_id, :user_id], unique: true + add_index :crew_memberships, :role + add_index :crew_memberships, :joined_at + + # Add constraint to limit subcaptains to 3 per crew + execute <<-SQL + CREATE OR REPLACE FUNCTION check_subcaptain_limit() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.role = 1 THEN + IF (SELECT COUNT(*) FROM crew_memberships + WHERE crew_id = NEW.crew_id AND role = 1 AND id != NEW.id) >= 3 THEN + RAISE EXCEPTION 'Maximum 3 subcaptains allowed per crew'; + END IF; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER enforce_subcaptain_limit + BEFORE INSERT OR UPDATE ON crew_memberships + FOR EACH ROW EXECUTE FUNCTION check_subcaptain_limit(); + SQL + + # Add constraint to limit crew size to 30 members + execute <<-SQL + CREATE OR REPLACE FUNCTION check_crew_member_limit() RETURNS TRIGGER AS $$ + BEGIN + IF (SELECT COUNT(*) FROM crew_memberships WHERE crew_id = NEW.crew_id) >= 30 THEN + RAISE EXCEPTION 'Maximum 30 members allowed per crew'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER enforce_crew_member_limit + BEFORE INSERT ON crew_memberships + FOR EACH ROW EXECUTE FUNCTION check_crew_member_limit(); + SQL + end +end +``` + +#### 1.3 Create CrewInvitations table + +```bash +rails generate migration CreateCrewInvitations +``` + +```ruby +# db/migrate/xxx_create_crew_invitations.rb +class CreateCrewInvitations < ActiveRecord::Migration[8.0] + def change + create_table :crew_invitations, id: :uuid do |t| + t.references :crew, type: :uuid, null: false, foreign_key: true + t.references :invited_by, type: :uuid, null: false, foreign_key: { to_table: :users } + t.string :token, null: false + t.datetime :expires_at, default: -> { "CURRENT_TIMESTAMP + INTERVAL '7 days'" }, null: false + t.datetime :used_at + t.references :used_by, type: :uuid, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :crew_invitations, :token, unique: true + add_index :crew_invitations, :expires_at + add_index :crew_invitations, [:crew_id, :used_at] + end +end +``` + +#### 1.4 Create UniteAndFights table + +```bash +rails generate migration CreateUniteAndFights +``` + +```ruby +# db/migrate/xxx_create_unite_and_fights.rb +class CreateUniteAndFights < ActiveRecord::Migration[8.0] + def change + create_table :unite_and_fights, id: :uuid do |t| + t.string :name, null: false + t.integer :event_number, null: false + t.datetime :starts_at, null: false + t.datetime :ends_at, null: false + t.references :created_by, type: :uuid, null: false, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :unite_and_fights, :event_number, unique: true + add_index :unite_and_fights, :starts_at + add_index :unite_and_fights, :ends_at + add_index :unite_and_fights, [:starts_at, :ends_at] + end +end +``` + +#### 1.5 Create UnfScores table + +```bash +rails generate migration CreateUnfScores +``` + +```ruby +# db/migrate/xxx_create_unf_scores.rb +class CreateUnfScores < ActiveRecord::Migration[8.0] + def change + create_table :unf_scores, id: :uuid do |t| + t.references :unite_and_fight, type: :uuid, null: false, foreign_key: true + t.references :crew, type: :uuid, null: false, foreign_key: true + t.references :user, type: :uuid, null: false, foreign_key: true + t.bigint :honors, default: 0, null: false + t.references :recorded_by, type: :uuid, null: false, foreign_key: { to_table: :users } + t.integer :day_number, null: false # 1-7 for each day of the event + + t.timestamps + end + + add_index :unf_scores, [:unite_and_fight_id, :crew_id, :user_id, :day_number], + unique: true, name: 'idx_unf_scores_unique' + add_index :unf_scores, [:crew_id, :unite_and_fight_id] + add_index :unf_scores, :honors + + # Validate day_number is between 1 and 7 + execute <<-SQL + ALTER TABLE unf_scores + ADD CONSTRAINT check_day_number + CHECK (day_number >= 1 AND day_number <= 7); + SQL + end +end +``` + +#### 1.6 Update Users table for crew association + +```bash +rails generate migration AddCrewIdToUsers +``` + +```ruby +# db/migrate/xxx_add_crew_id_to_users.rb +class AddCrewIdToUsers < ActiveRecord::Migration[8.0] + def change + # Note: We don't add crew_id directly to users table + # The relationship is through crew_memberships table + # This migration is for updating collection_viewable_by? logic + + # Update the collection_viewable_by method in User model to check crew membership + end +end +``` + +### Step 2: Create Models + +#### 2.1 Crew model + +```ruby +# app/models/crew.rb +class Crew < ApplicationRecord + # Associations + 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 + + # Scopes for specific roles + has_many :subcaptains, -> { where(crew_memberships: { role: 1 }) }, + through: :crew_memberships, source: :user + + # Validations + validates :name, presence: true, uniqueness: true, + length: { minimum: 2, maximum: 30 } + validates :gamertag, length: { is: 4 }, allow_blank: true, + uniqueness: { case_sensitive: false }, + format: { with: /\A[A-Z0-9]+\z/i, message: "only alphanumeric characters allowed" } + validates :rules, length: { maximum: 5000 } + validates :member_count, numericality: { greater_than: 0, less_than_or_equal_to: 30 } + + # Callbacks + after_create :create_captain_membership + + # Methods + def full? + member_count >= 30 + end + + def has_subcaptain_slots? + crew_memberships.where(role: 1).count < 3 + end + + def active_invitations + crew_invitations.where(used_at: nil).where('expires_at > ?', Time.current) + end + + def subcaptain_count + crew_memberships.where(role: 1).count + end + + def blueprint + CrewBlueprint + end + + private + + def create_captain_membership + crew_memberships.create!( + user: captain, + role: 2, # captain role + display_gamertag: true + ) + end +end +``` + +#### 2.2 CrewMembership model + +```ruby +# app/models/crew_membership.rb +class CrewMembership < ApplicationRecord + # Associations + belongs_to :crew, counter_cache: :member_count + belongs_to :user + + # Enums + enum role: { + member: 0, + subcaptain: 1, + captain: 2 + } + + # Validations + validates :user_id, uniqueness: { scope: :crew_id, + message: "is already a member of this crew" } + validate :validate_subcaptain_limit, if: :subcaptain? + validate :validate_single_crew_membership, on: :create + + # Scopes + scope :officers, -> { where(role: [1, 2]) } # subcaptains and captain + scope :by_join_date, -> { order(joined_at: :asc) } + scope :displaying_gamertag, -> { where(display_gamertag: true) } + + # Callbacks + before_validation :set_joined_at, on: :create + + def blueprint + CrewMembershipBlueprint + end + + private + + def validate_subcaptain_limit + return unless role_changed? && subcaptain? + + if crew.subcaptain_count >= 3 + errors.add(:role, "Maximum 3 subcaptains allowed per crew") + end + end + + def validate_single_crew_membership + if user.crew_membership.present? + errors.add(:user, "is already a member of another crew") + end + end + + def set_joined_at + self.joined_at ||= Time.current + end +end +``` + +#### 2.3 CrewInvitation model + +```ruby +# app/models/crew_invitation.rb +class CrewInvitation < ApplicationRecord + # Associations + belongs_to :crew + belongs_to :invited_by, class_name: 'User' + belongs_to :used_by, class_name: 'User', optional: true + + # Validations + validates :token, presence: true, uniqueness: true + validate :crew_not_full, on: :create + + # Callbacks + before_validation :generate_token, on: :create + before_create :set_expiration + + # Scopes + scope :active, -> { where(used_at: nil).where('expires_at > ?', Time.current) } + scope :expired, -> { where(used_at: nil).where('expires_at <= ?', Time.current) } + scope :used, -> { where.not(used_at: nil) } + + def expired? + expires_at < Time.current + end + + def used? + used_at.present? + end + + def valid_for_use? + !expired? && !used? && !crew.full? + end + + def use_by!(user) + return false unless valid_for_use? + return false if user.crew_membership.present? + + transaction do + update!(used_at: Time.current, used_by: user) + crew.crew_memberships.create!(user: user, role: :member) + end + true + rescue ActiveRecord::RecordInvalid + false + end + + def invitation_url + "#{Rails.application.config.frontend_url}/crews/join?token=#{token}" + end + + def blueprint + CrewInvitationBlueprint + end + + private + + def generate_token + self.token ||= SecureRandom.urlsafe_base64(32) + end + + def set_expiration + self.expires_at ||= 7.days.from_now + end + + def crew_not_full + errors.add(:crew, "is already full") if crew.full? + end +end +``` + +#### 2.4 UniteAndFight model + +```ruby +# app/models/unite_and_fight.rb +class UniteAndFight < ApplicationRecord + # Associations + has_many :unf_scores, dependent: :destroy + belongs_to :created_by, class_name: 'User' + + # Validations + validates :name, presence: true + validates :event_number, presence: true, uniqueness: true, + numericality: { greater_than: 0 } + validates :starts_at, presence: true + validates :ends_at, presence: true + validate :end_after_start + validate :duration_is_one_week + + # Scopes + scope :current, -> { where('starts_at <= ? AND ends_at >= ?', Time.current, Time.current) } + scope :upcoming, -> { where('starts_at > ?', Time.current).order(starts_at: :asc) } + scope :past, -> { where('ends_at < ?', Time.current).order(ends_at: :desc) } + + def active? + starts_at <= Time.current && ends_at >= Time.current + end + + def upcoming? + starts_at > Time.current + end + + def past? + ends_at < Time.current + end + + def day_number_for(date = Date.current) + return nil unless date.between?(starts_at.to_date, ends_at.to_date) + (date - starts_at.to_date).to_i + 1 + end + + def blueprint + UniteAndFightBlueprint + end + + private + + def end_after_start + return unless starts_at && ends_at + errors.add(:ends_at, "must be after start date") if ends_at <= starts_at + end + + def duration_is_one_week + return unless starts_at && ends_at + duration = (ends_at - starts_at).to_i / 1.day + errors.add(:base, "Event must last exactly 7 days") unless duration == 7 + end +end +``` + +#### 2.5 UnfScore model + +```ruby +# app/models/unf_score.rb +class UnfScore < ApplicationRecord + # Associations + belongs_to :unite_and_fight + belongs_to :crew + belongs_to :user + belongs_to :recorded_by, class_name: 'User' + + # Validations + validates :honors, presence: true, + numericality: { greater_than_or_equal_to: 0 } + validates :day_number, presence: true, + inclusion: { in: 1..7 } + validates :user_id, uniqueness: { + scope: [:unite_and_fight_id, :crew_id, :day_number], + message: "already has a score for this day" + } + validate :user_is_crew_member + validate :day_within_event + + # Scopes + scope :for_event, ->(event) { where(unite_and_fight: event) } + scope :for_crew, ->(crew) { where(crew: crew) } + scope :for_user, ->(user) { where(user: user) } + scope :by_day, ->(day) { where(day_number: day) } + scope :total_honors, -> { sum(:honors) } + + # Class methods for aggregation + def self.user_totals_for_event(event, crew) + for_event(event) + .for_crew(crew) + .group(:user_id) + .sum(:honors) + .sort_by { |_user_id, honors| -honors } + end + + def self.daily_totals_for_crew(event, crew) + for_event(event) + .for_crew(crew) + .group(:day_number) + .sum(:honors) + end + + def blueprint + UnfScoreBlueprint + end + + private + + def user_is_crew_member + return unless user && crew + unless user.member_of?(crew) + errors.add(:user, "must be a member of the crew") + end + end + + def day_within_event + return unless unite_and_fight && day_number + max_day = unite_and_fight.day_number_for(unite_and_fight.ends_at.to_date) + if day_number > max_day + errors.add(:day_number, "exceeds event duration") + end + end +end +``` + +#### 2.6 Update User model + +```ruby +# app/models/user.rb - Add these associations and methods + +# Associations +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 role checking methods +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 + +def in_same_crew_as?(other_user) + return false unless other_user.present? && crew_membership.present? + crew_membership.crew_id == other_user.crew_membership&.crew_id +end + +# Update collection_viewable_by? to support crew_only privacy +def collection_viewable_by?(viewer) + return true if self == viewer + + case collection_privacy + when 'public' + true + when 'crew_only' + viewer.present? && in_same_crew_as?(viewer) + when 'private' + false + else + false + end +end +``` + +### Step 3: Create Blueprints + +#### 3.1 CrewBlueprint + +```ruby +# app/blueprints/api/v1/crew_blueprint.rb +module Api + module V1 + class CrewBlueprint < ApiBlueprint + identifier :id + + fields :name, :gamertag, :rules, :member_count, + :created_at, :updated_at + + association :captain, blueprint: UserBlueprint, view: :basic + + view :with_members do + association :members, blueprint: UserBlueprint, view: :basic do |crew, options| + crew.crew_memberships.includes(:user).map do |membership| + { + user: UserBlueprint.render_as_hash(membership.user, view: :basic), + role: membership.role, + joined_at: membership.joined_at, + display_gamertag: membership.display_gamertag + } + end + end + end + + view :full do + include_view :with_members + field :subcaptain_slots_available do |crew| + 3 - crew.subcaptain_count + end + field :is_full do |crew| + crew.full? + end + end + end + end +end +``` + +#### 3.2 CrewMembershipBlueprint + +```ruby +# app/blueprints/api/v1/crew_membership_blueprint.rb +module Api + module V1 + class CrewMembershipBlueprint < ApiBlueprint + identifier :id + + fields :role, :display_gamertag, :joined_at, + :created_at, :updated_at + + association :user, blueprint: UserBlueprint, view: :basic + association :crew, blueprint: CrewBlueprint + + view :full do + association :crew, blueprint: CrewBlueprint, view: :with_members + end + end + end +end +``` + +#### 3.3 CrewInvitationBlueprint + +```ruby +# app/blueprints/api/v1/crew_invitation_blueprint.rb +module Api + module V1 + class CrewInvitationBlueprint < ApiBlueprint + identifier :id + + fields :token, :expires_at, :used_at, :created_at + + field :invitation_url do |invitation| + invitation.invitation_url + end + + field :is_expired do |invitation| + invitation.expired? + end + + field :is_used do |invitation| + invitation.used? + end + + association :invited_by, blueprint: UserBlueprint, view: :basic + association :used_by, blueprint: UserBlueprint, view: :basic, + if: ->(_, invitation, _) { invitation.used_by.present? } + + view :full do + association :crew, blueprint: CrewBlueprint + end + end + end +end +``` + +#### 3.4 UniteAndFightBlueprint + +```ruby +# app/blueprints/api/v1/unite_and_fight_blueprint.rb +module Api + module V1 + class UniteAndFightBlueprint < ApiBlueprint + identifier :id + + fields :name, :event_number, :starts_at, :ends_at, + :created_at, :updated_at + + field :status do |unf| + if unf.active? + 'active' + elsif unf.upcoming? + 'upcoming' + else + 'past' + end + end + + field :current_day do |unf| + unf.day_number_for(Date.current) if unf.active? + end + + association :created_by, blueprint: UserBlueprint, view: :basic + end + end +end +``` + +#### 3.5 UnfScoreBlueprint + +```ruby +# app/blueprints/api/v1/unf_score_blueprint.rb +module Api + module V1 + class UnfScoreBlueprint < ApiBlueprint + identifier :id + + fields :honors, :day_number, :created_at, :updated_at + + association :user, blueprint: UserBlueprint, view: :basic + association :recorded_by, blueprint: UserBlueprint, view: :basic + + view :with_event do + association :unite_and_fight, blueprint: UniteAndFightBlueprint + end + + view :with_crew do + association :crew, blueprint: CrewBlueprint + end + + view :full do + include_view :with_event + include_view :with_crew + end + end + end +end +``` + +### Step 4: Create Controllers + +#### 4.1 CrewsController + +```ruby +# app/controllers/api/v1/crews_controller.rb +module Api + module V1 + class CrewsController < ApiController + before_action :authenticate_user!, except: [:show] + before_action :set_crew, only: [:show, :update, :destroy] + before_action :authorize_captain!, only: [:destroy] + before_action :authorize_manager!, only: [:update] + + def create + @crew = Crew.new(crew_params) + @crew.captain = current_user + + if current_user.crew_membership.present? + render json: { error: "You are already a member of a crew" }, + status: :unprocessable_entity + return + end + + if @crew.save + render json: CrewBlueprint.render(@crew, view: :full), status: :created + else + render_errors(@crew.errors) + end + end + + def show + render json: CrewBlueprint.render(@crew, view: :full) + end + + def update + if @crew.update(crew_params) + render json: CrewBlueprint.render(@crew, view: :full) + else + render_errors(@crew.errors) + end + end + + def destroy + @crew.destroy + head :no_content + end + + def my + authenticate_user! + + if current_user.crew + render json: CrewBlueprint.render(current_user.crew, view: :full) + else + render json: { error: "You are not a member of any crew" }, + status: :not_found + end + end + + private + + def set_crew + @crew = Crew.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Crew not found" }, status: :not_found + end + + def crew_params + params.require(:crew).permit(:name, :rules, :gamertag) + end + + def authorize_captain! + unless current_user.captain_of?(@crew) + render json: { error: "Only the captain can perform this action" }, + status: :forbidden + end + end + + def authorize_manager! + unless current_user.can_manage_crew?(@crew) + render json: { error: "You don't have permission to manage this crew" }, + status: :forbidden + end + end + end + end +end +``` + +#### 4.2 CrewMembersController + +```ruby +# app/controllers/api/v1/crew_members_controller.rb +module Api + module V1 + class CrewMembersController < ApiController + before_action :authenticate_user! + before_action :set_crew + before_action :set_member, only: [:destroy] + before_action :authorize_captain!, only: [:promote, :destroy] + + def index + @memberships = @crew.crew_memberships + .includes(:user) + .by_join_date + .page(params[:page]) + .per(params[:limit] || 30) + + render json: CrewMembershipBlueprint.render( + @memberships, + root: :members, + meta: pagination_meta(@memberships) + ) + end + + def promote + @member = @crew.members.find(params[:user_id]) + @membership = @crew.crew_memberships.find_by(user: @member) + + if params[:role] == 'subcaptain' + unless @crew.has_subcaptain_slots? + render json: { error: "Maximum subcaptains reached" }, + status: :unprocessable_entity + return + end + + @membership.subcaptain! + render json: CrewMembershipBlueprint.render(@membership) + elsif params[:role] == 'member' + @membership.member! + render json: CrewMembershipBlueprint.render(@membership) + else + render json: { error: "Invalid role" }, status: :unprocessable_entity + end + end + + def destroy + if @member == @crew.captain + render json: { error: "Cannot remove the captain" }, + status: :unprocessable_entity + return + end + + @membership = @crew.crew_memberships.find_by(user: @member) + @membership.destroy + head :no_content + end + + def update_me + @membership = current_user.crew_membership + + unless @membership && @membership.crew_id == @crew.id + render json: { error: "You are not a member of this crew" }, + status: :forbidden + return + end + + if @membership.update(my_membership_params) + render json: CrewMembershipBlueprint.render(@membership) + else + render_errors(@membership.errors) + end + end + + def leave + @membership = current_user.crew_membership + + unless @membership && @membership.crew_id == @crew.id + render json: { error: "You are not a member of this crew" }, + status: :forbidden + return + end + + if @membership.captain? + render json: { error: "Captain cannot leave the crew. Transfer ownership or disband the crew." }, + status: :unprocessable_entity + return + end + + @membership.destroy + head :no_content + end + + private + + def set_crew + @crew = Crew.find(params[:crew_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Crew not found" }, status: :not_found + end + + def set_member + @member = User.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Member not found" }, status: :not_found + end + + def my_membership_params + params.permit(:display_gamertag) + end + + def authorize_captain! + unless current_user.captain_of?(@crew) + render json: { error: "Only the captain can perform this action" }, + status: :forbidden + end + end + end + end +end +``` + +#### 4.3 CrewInvitationsController + +```ruby +# app/controllers/api/v1/crew_invitations_controller.rb +module Api + module V1 + class CrewInvitationsController < ApiController + before_action :authenticate_user! + before_action :set_crew, except: [:join] + before_action :authorize_inviter!, except: [:join] + + def create + if @crew.full? + render json: { error: "Crew is full" }, status: :unprocessable_entity + return + end + + @invitation = @crew.crew_invitations.build(invited_by: current_user) + + if @invitation.save + render json: CrewInvitationBlueprint.render(@invitation, view: :full), + status: :created + else + render_errors(@invitation.errors) + end + end + + def index + @invitations = @crew.active_invitations + .includes(:invited_by, :used_by) + .page(params[:page]) + .per(params[:limit] || 20) + + render json: CrewInvitationBlueprint.render( + @invitations, + root: :invitations, + meta: pagination_meta(@invitations) + ) + end + + def destroy + @invitation = @crew.crew_invitations.find(params[:id]) + + if @invitation.used? + render json: { error: "Cannot revoke a used invitation" }, + status: :unprocessable_entity + return + end + + @invitation.destroy + head :no_content + end + + def join + @invitation = CrewInvitation.find_by(token: params[:token]) + + unless @invitation + render json: { error: "Invalid invitation" }, status: :not_found + return + end + + unless @invitation.valid_for_use? + error = if @invitation.expired? + "Invitation has expired" + elsif @invitation.used? + "Invitation has already been used" + else + "Crew is full" + end + render json: { error: error }, status: :unprocessable_entity + return + end + + if current_user.crew_membership.present? + render json: { error: "You are already a member of a crew" }, + status: :unprocessable_entity + return + end + + if @invitation.use_by!(current_user) + render json: CrewBlueprint.render(@invitation.crew, view: :full) + else + render json: { error: "Failed to join crew" }, + status: :unprocessable_entity + end + end + + private + + def set_crew + @crew = Crew.find(params[:crew_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Crew not found" }, status: :not_found + end + + def authorize_inviter! + unless current_user.can_invite_to_crew?(@crew) + render json: { error: "You don't have permission to manage invitations" }, + status: :forbidden + end + end + end + end +end +``` + +#### 4.4 UniteAndFightsController + +```ruby +# app/controllers/api/v1/unite_and_fights_controller.rb +module Api + module V1 + class UniteAndFightsController < ApiController + before_action :authenticate_user!, except: [:index, :show] + before_action :require_admin!, only: [:create, :update, :destroy] + before_action :set_unite_and_fight, only: [:show, :update, :destroy] + + def index + @unite_and_fights = UniteAndFight.all.order(event_number: :desc) + + @unite_and_fights = case params[:status] + when 'current' + @unite_and_fights.current + when 'upcoming' + @unite_and_fights.upcoming + when 'past' + @unite_and_fights.past + else + @unite_and_fights + end + + @unite_and_fights = @unite_and_fights.page(params[:page]).per(params[:limit] || 20) + + render json: UniteAndFightBlueprint.render( + @unite_and_fights, + root: :unite_and_fights, + meta: pagination_meta(@unite_and_fights) + ) + end + + def show + render json: UniteAndFightBlueprint.render(@unite_and_fight) + end + + def create + @unite_and_fight = UniteAndFight.new(unite_and_fight_params) + @unite_and_fight.created_by = current_user + + if @unite_and_fight.save + render json: UniteAndFightBlueprint.render(@unite_and_fight), + status: :created + else + render_errors(@unite_and_fight.errors) + end + end + + def update + if @unite_and_fight.update(unite_and_fight_params) + render json: UniteAndFightBlueprint.render(@unite_and_fight) + else + render_errors(@unite_and_fight.errors) + end + end + + def destroy + @unite_and_fight.destroy + head :no_content + end + + private + + def set_unite_and_fight + @unite_and_fight = UniteAndFight.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Unite and Fight event not found" }, status: :not_found + end + + def unite_and_fight_params + params.require(:unite_and_fight).permit(:name, :event_number, :starts_at, :ends_at) + end + + def require_admin! + unless current_user.role >= 7 + render json: { error: "Admin access required" }, status: :forbidden + end + end + end + end +end +``` + +#### 4.5 UnfScoresController + +```ruby +# app/controllers/api/v1/unf_scores_controller.rb +module Api + module V1 + class UnfScoresController < ApiController + before_action :authenticate_user! + before_action :set_crew, except: [:performance] + before_action :authorize_scorer!, only: [:create, :update] + + def create + @unf_score = UnfScore.find_or_initialize_by( + unite_and_fight_id: params[:unite_and_fight_id], + crew_id: @crew.id, + user_id: params[:user_id], + day_number: params[:day_number] + ) + + @unf_score.honors = params[:honors] + @unf_score.recorded_by = current_user + + if @unf_score.save + render json: UnfScoreBlueprint.render(@unf_score, view: :full), + status: :created + else + render_errors(@unf_score.errors) + end + end + + def index + @scores = @crew.unf_scores.includes(:user, :unite_and_fight, :recorded_by) + + if params[:unite_and_fight_id] + @scores = @scores.where(unite_and_fight_id: params[:unite_and_fight_id]) + end + + if params[:user_id] + @scores = @scores.where(user_id: params[:user_id]) + end + + if params[:day_number] + @scores = @scores.where(day_number: params[:day_number]) + end + + @scores = @scores.order(day_number: :asc, honors: :desc) + .page(params[:page]) + .per(params[:limit] || 50) + + render json: UnfScoreBlueprint.render( + @scores, + root: :scores, + meta: pagination_meta(@scores) + ) + end + + def performance + authenticate_user! + + crew_id = params[:crew_id] + unless crew_id + render json: { error: "crew_id is required" }, status: :bad_request + return + end + + @crew = Crew.find(crew_id) + + # Check if user can view crew scores + unless current_user.member_of?(@crew) + render json: { error: "You must be a crew member to view scores" }, + status: :forbidden + return + end + + # Build performance query + scores = UnfScore.for_crew(@crew) + + if params[:user_id] + scores = scores.for_user(params[:user_id]) + end + + if params[:from_date] + from_date = Date.parse(params[:from_date]) + events = UniteAndFight.where('ends_at >= ?', from_date) + scores = scores.where(unite_and_fight: events) + end + + if params[:to_date] + to_date = Date.parse(params[:to_date]) + events = UniteAndFight.where('starts_at <= ?', to_date) + scores = scores.where(unite_and_fight: events) + end + + # Group by event and aggregate + performance_data = scores.includes(:unite_and_fight, :user) + .group_by(&:unite_and_fight) + .map do |event, event_scores| + { + event: UniteAndFightBlueprint.render_as_hash(event), + total_honors: event_scores.sum(&:honors), + daily_totals: event_scores.group_by(&:day_number) + .transform_values { |s| s.sum(&:honors) }, + user_totals: event_scores.group_by(&:user) + .transform_keys { |u| u.id } + .transform_values { |s| s.sum(&:honors) } + } + end + + render json: { performance: performance_data } + end + + private + + def set_crew + @crew = Crew.find(params[:crew_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Crew not found" }, status: :not_found + end + + def authorize_scorer! + unless current_user.can_record_unf_scores?(@crew) + render json: { error: "You don't have permission to record scores" }, + status: :forbidden + end + end + end + end +end +``` + +### Step 5: Update Routes + +```ruby +# config/routes.rb - Add these routes within the API scope + +# Crew management +resources :crews, only: [:create, :show, :update, :destroy] do + collection do + get 'my', to: 'crews#my' + end + + # Crew members + resources :members, controller: 'crew_members', only: [:index, :destroy] do + collection do + post 'promote' + put 'me', to: 'crew_members#update_me' + delete 'leave', to: 'crew_members#leave' + end + end + + # Invitations + resources :invitations, controller: 'crew_invitations', only: [:create, :index, :destroy] + + # UnF scores for this crew + resources :unf_scores, only: [:create, :index] +end + +# Join crew via invitation +post 'crews/join', to: 'crew_invitations#join' + +# Unite and Fight events +resources :unite_and_fights + +# UnF score performance analytics +get 'unf_scores/performance', to: 'unf_scores#performance' +``` + +### Step 6: Add Authorization Concerns + +```ruby +# app/controllers/concerns/crew_authorization_concern.rb +module CrewAuthorizationConcern + extend ActiveSupport::Concern + + private + + def require_crew_membership + unless current_user.crew_membership.present? + render json: { error: "You must be a member of a crew" }, status: :forbidden + false + end + end + + def require_crew_captain + return false unless require_crew_membership + + unless current_user.crew_membership.captain? + render json: { error: "Only the captain can perform this action" }, + status: :forbidden + false + end + end + + def require_crew_manager + return false unless require_crew_membership + + unless current_user.crew_membership.captain? || current_user.crew_membership.subcaptain? + render json: { error: "Only captains and subcaptains can perform this action" }, + status: :forbidden + false + end + end +end +``` + +## Testing the Implementation + +### Manual Testing Steps + +1. **Create a crew** +```bash +curl -X POST http://localhost:3000/api/v1/crews \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"crew": {"name": "Test Crew", "gamertag": "TEST"}}' +``` + +2. **Generate invitation** +```bash +curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/invitations \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +3. **Join crew** +```bash +curl -X POST http://localhost:3000/api/v1/crews/join \ + -H "Authorization: Bearer OTHER_USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"token": "INVITATION_TOKEN"}' +``` + +4. **Promote to subcaptain** +```bash +curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/members/promote \ + -H "Authorization: Bearer CAPTAIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "USER_ID", "role": "subcaptain"}' +``` + +5. **Record UnF score** +```bash +curl -X POST http://localhost:3000/api/v1/crews/CREW_ID/unf_scores \ + -H "Authorization: Bearer CAPTAIN_OR_SUBCAPTAIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "unite_and_fight_id": "UNF_ID", + "user_id": "MEMBER_ID", + "honors": 1000000, + "day_number": 1 + }' +``` + +## Deployment Checklist + +- [ ] Run all migrations in order +- [ ] Verify database constraints are created +- [ ] Test crew size limits (30 members) +- [ ] Test subcaptain limits (3 per crew) +- [ ] Verify invitation expiration +- [ ] Test UnF score recording +- [ ] Verify collection privacy integration +- [ ] Set up background job for invitation cleanup +- [ ] Configure rate limiting for invitations +- [ ] Update API documentation +- [ ] Deploy frontend changes +- [ ] Monitor for performance issues +- [ ] Prepare rollback plan + +## Performance Optimizations + +1. **Add caching for crew member lists** +```ruby +def members_cache_key + "crew_#{id}_members_#{updated_at}" +end +``` + +2. **Background job for expired invitations cleanup** +```ruby +class CleanupExpiredInvitationsJob < ApplicationJob + def perform + CrewInvitation.expired.destroy_all + end +end +``` + +3. **Optimize UnF score queries** +```ruby +# Add composite indexes for common query patterns +add_index :unf_scores, [:crew_id, :unite_and_fight_id, :user_id] +add_index :unf_scores, [:unite_and_fight_id, :honors] +``` + +## Next Steps + +1. Implement crew feed functionality +2. Add real-time notifications for crew events +3. Create crew chat system +4. Build crew discovery and recruitment features +5. Add crew achievements and milestones +6. Implement crew-vs-crew competitions +7. Create mobile push notifications +8. Add crew resource sharing system \ No newline at end of file diff --git a/docs/importers.md b/docs/importers.md new file mode 100644 index 0000000..62b5d48 --- /dev/null +++ b/docs/importers.md @@ -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') +``` \ No newline at end of file diff --git a/docs/parsers.md b/docs/parsers.md new file mode 100644 index 0000000..fcf2c98 --- /dev/null +++ b/docs/parsers.md @@ -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 \ No newline at end of file diff --git a/docs/planning/artifacts-feature-plan.md b/docs/planning/artifacts-feature-plan.md new file mode 100644 index 0000000..e6f9ace --- /dev/null +++ b/docs/planning/artifacts-feature-plan.md @@ -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 \ No newline at end of file diff --git a/docs/planning/collection-tracking-plan.md b/docs/planning/collection-tracking-plan.md new file mode 100644 index 0000000..fd52efe --- /dev/null +++ b/docs/planning/collection-tracking-plan.md @@ -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 \ No newline at end of file diff --git a/docs/planning/crew-feature-plan.md b/docs/planning/crew-feature-plan.md new file mode 100644 index 0000000..9f2c147 --- /dev/null +++ b/docs/planning/crew-feature-plan.md @@ -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 \ No newline at end of file diff --git a/docs/rake-tasks.md b/docs/rake-tasks.md new file mode 100644 index 0000000..b1b3265 --- /dev/null +++ b/docs/rake-tasks.md @@ -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 +``` \ No newline at end of file diff --git a/docs/rspec-test-analysis.md b/docs/rspec-test-analysis.md new file mode 100644 index 0000000..b321272 --- /dev/null +++ b/docs/rspec-test-analysis.md @@ -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. \ No newline at end of file diff --git a/docs/transformers.md b/docs/transformers.md new file mode 100644 index 0000000..9de460b --- /dev/null +++ b/docs/transformers.md @@ -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 \ No newline at end of file diff --git a/lib/granblue/downloaders/base_downloader.rb b/lib/granblue/downloaders/base_downloader.rb index 8f1b928..fbe0966 100644 --- a/lib/granblue/downloaders/base_downloader.rb +++ b/lib/granblue/downloaders/base_downloader.rb @@ -29,6 +29,25 @@ module Granblue # @return [Array] 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 diff --git a/lib/granblue/downloaders/character_downloader.rb b/lib/granblue/downloaders/character_downloader.rb index feed390..24c60ab 100644 --- a/lib/granblue/downloaders/character_downloader.rb +++ b/lib/granblue/downloaders/character_downloader.rb @@ -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 diff --git a/lib/granblue/downloaders/job_downloader.rb b/lib/granblue/downloaders/job_downloader.rb new file mode 100644 index 0000000..20e8129 --- /dev/null +++ b/lib/granblue/downloaders/job_downloader.rb @@ -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 \ No newline at end of file diff --git a/lib/granblue/downloaders/summon_downloader.rb b/lib/granblue/downloaders/summon_downloader.rb index 39a19a4..cc34d54 100644 --- a/lib/granblue/downloaders/summon_downloader.rb +++ b/lib/granblue/downloaders/summon_downloader.rb @@ -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 diff --git a/lib/granblue/downloaders/weapon_downloader.rb b/lib/granblue/downloaders/weapon_downloader.rb index 5207f97..e59e2d5 100644 --- a/lib/granblue/downloaders/weapon_downloader.rb +++ b/lib/granblue/downloaders/weapon_downloader.rb @@ -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 diff --git a/lib/tasks/export_character.rake b/lib/tasks/export_character.rake index cdcd3e1..8d76c7f 100644 --- a/lib/tasks/export_character.rake +++ b/lib/tasks/export_character.rake @@ -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' diff --git a/lib/tasks/export_job.rake b/lib/tasks/export_job.rake index 32f640d..65ea140 100644 --- a/lib/tasks/export_job.rake +++ b/lib/tasks/export_job.rake @@ -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 diff --git a/lib/tasks/export_summon.rake b/lib/tasks/export_summon.rake index 6055807..5775255 100644 --- a/lib/tasks/export_summon.rake +++ b/lib/tasks/export_summon.rake @@ -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' diff --git a/lib/tasks/export_weapon.rake b/lib/tasks/export_weapon.rake index 9c4292e..2df854f 100644 --- a/lib/tasks/export_weapon.rake +++ b/lib/tasks/export_weapon.rake @@ -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' diff --git a/spec/factories/awakenings.rb b/spec/factories/awakenings.rb new file mode 100644 index 0000000..d544c8e --- /dev/null +++ b/spec/factories/awakenings.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/characters.rb b/spec/factories/characters.rb new file mode 100644 index 0000000..5a7b67c --- /dev/null +++ b/spec/factories/characters.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/collection_characters.rb b/spec/factories/collection_characters.rb new file mode 100644 index 0000000..519cb2c --- /dev/null +++ b/spec/factories/collection_characters.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/collection_job_accessories.rb b/spec/factories/collection_job_accessories.rb new file mode 100644 index 0000000..b13fe6f --- /dev/null +++ b/spec/factories/collection_job_accessories.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/collection_summons.rb b/spec/factories/collection_summons.rb new file mode 100644 index 0000000..1dfda0c --- /dev/null +++ b/spec/factories/collection_summons.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/collection_weapons.rb b/spec/factories/collection_weapons.rb new file mode 100644 index 0000000..ac75d63 --- /dev/null +++ b/spec/factories/collection_weapons.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/job_accessories.rb b/spec/factories/job_accessories.rb new file mode 100644 index 0000000..cac5fe0 --- /dev/null +++ b/spec/factories/job_accessories.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/jobs.rb b/spec/factories/jobs.rb new file mode 100644 index 0000000..ff2c2f3 --- /dev/null +++ b/spec/factories/jobs.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/summons.rb b/spec/factories/summons.rb new file mode 100644 index 0000000..d866f2c --- /dev/null +++ b/spec/factories/summons.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/weapon_keys.rb b/spec/factories/weapon_keys.rb index 954d772..6fdc438 100644 --- a/spec/factories/weapon_keys.rb +++ b/spec/factories/weapon_keys.rb @@ -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 \ No newline at end of file diff --git a/spec/factories/weapons.rb b/spec/factories/weapons.rb index 78a3a02..57fc132 100644 --- a/spec/factories/weapons.rb +++ b/spec/factories/weapons.rb @@ -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 \ No newline at end of file diff --git a/spec/models/collection_character_spec.rb b/spec/models/collection_character_spec.rb new file mode 100644 index 0000000..bdfda41 --- /dev/null +++ b/spec/models/collection_character_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/models/collection_job_accessory_spec.rb b/spec/models/collection_job_accessory_spec.rb new file mode 100644 index 0000000..aa9279b --- /dev/null +++ b/spec/models/collection_job_accessory_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/models/collection_summon_spec.rb b/spec/models/collection_summon_spec.rb new file mode 100644 index 0000000..78cdcd3 --- /dev/null +++ b/spec/models/collection_summon_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/models/collection_weapon_spec.rb b/spec/models/collection_weapon_spec.rb new file mode 100644 index 0000000..cdb1de4 --- /dev/null +++ b/spec/models/collection_weapon_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 47a31bb..9a66e71 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/requests/collection_characters_controller_spec.rb b/spec/requests/collection_characters_controller_spec.rb new file mode 100644 index 0000000..9acf461 --- /dev/null +++ b/spec/requests/collection_characters_controller_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/requests/collection_controller_spec.rb b/spec/requests/collection_controller_spec.rb new file mode 100644 index 0000000..1993e8a --- /dev/null +++ b/spec/requests/collection_controller_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/requests/collection_job_accessories_controller_spec.rb b/spec/requests/collection_job_accessories_controller_spec.rb new file mode 100644 index 0000000..b34b63d --- /dev/null +++ b/spec/requests/collection_job_accessories_controller_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/requests/collection_summons_controller_spec.rb b/spec/requests/collection_summons_controller_spec.rb new file mode 100644 index 0000000..9d0a36f --- /dev/null +++ b/spec/requests/collection_summons_controller_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/requests/collection_weapons_controller_spec.rb b/spec/requests/collection_weapons_controller_spec.rb new file mode 100644 index 0000000..89351e8 --- /dev/null +++ b/spec/requests/collection_weapons_controller_spec.rb @@ -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 \ No newline at end of file