Compare commits
229 commits
main
...
fix/weapon
| Author | SHA1 | Date | |
|---|---|---|---|
| f57c361221 | |||
| 53cb15fa27 | |||
| c6d117fc09 | |||
| 7bda9a1432 | |||
| 1f80e4189f | |||
| 964b73fda1 | |||
| a6b7e26210 | |||
| 5c578ee527 | |||
| 3abc10a5e2 | |||
| 65ad500550 | |||
| 85d9060dc9 | |||
| 405d0ea88c | |||
| 56ee499908 | |||
| c99180b299 | |||
| c39cc5d240 | |||
| 25aa1f3a62 | |||
| 847a487920 | |||
| 4bc2b7055e | |||
| 7a084920f1 | |||
| 5c38629f1f | |||
| e87650a2b0 | |||
| 5ea5388bed | |||
| 3afee9463f | |||
| 7222353d29 | |||
| 5da86c5405 | |||
| 693962ce3b | |||
| ab19403904 | |||
| 9ce86b22b4 | |||
| efa8dec43a | |||
| 9b63e99788 | |||
| 4e82e552d5 | |||
| 98c3eee313 | |||
| b3dadf24ef | |||
| b7aeb2bdfe | |||
| db2aa43d81 | |||
| 3390eaf755 | |||
| b033d7f74e | |||
| 86e5b9fffb | |||
| 3b9eab8b79 | |||
| 6c12a202ff | |||
| af061f3ab2 | |||
| 0c595792f7 | |||
| 1520fd8d2f | |||
| ad5c9893e4 | |||
| 687f7ae926 | |||
| 4a6ae93d20 | |||
| cc722b9660 | |||
| e60f3c48d6 | |||
| 3e21cb697d | |||
| 42f3d3a9cf | |||
| 5afd31fdb6 | |||
| de72d21e24 | |||
| 75862aec03 | |||
| 0c9d1d8e06 | |||
| 7e548109d6 | |||
| 9f2d9abdb5 | |||
| 7f2db88a6c | |||
| 00a9b61d92 | |||
| 3ac6829a45 | |||
| 244e3f51eb | |||
| 93e3526d1e | |||
| 579736e981 | |||
| c17dbfbcc7 | |||
| d613da4428 | |||
| b341185b54 | |||
| 834192dc11 | |||
| b458335e31 | |||
| b91ef0a4dd | |||
| 056aa3676f | |||
| d54af86dc1 | |||
| df6d811736 | |||
| f28e61b303 | |||
| acf8010669 | |||
| ee96bf3ce8 | |||
| b141cd07c4 | |||
| f083258552 | |||
| 57b5cd0d33 | |||
| f589f58eb5 | |||
| 9e72e828f7 | |||
| 4d75835c71 | |||
| 65a10abe6d | |||
| 1054920fcb | |||
| 3601579a7b | |||
| e1d212c764 | |||
| c4e42b0968 | |||
| ead6f45802 | |||
| 3b5b8412d3 | |||
| e7e9bd0f86 | |||
| 1caffecdad | |||
| 349b542c0e | |||
| d4131cf51d | |||
| 371f2a29dd | |||
| 5666ee300c | |||
| 513f8c6a66 | |||
| 07f23e2b74 | |||
| e0ba2d98c3 | |||
| f4ef04881e | |||
| fb253adf45 | |||
| 4224dcb257 | |||
| 981feff814 | |||
| 56280eb0ff | |||
| 6f3f0d92ff | |||
| ef7c158736 | |||
| b8947dbaf3 | |||
| 272f612357 | |||
| c498278c89 | |||
| 36dc4207c9 | |||
| aa198f072b | |||
| 860177c0a4 | |||
| 534414939b | |||
| 4db5f4224e | |||
| 0a069c0324 | |||
| d4c88997ff | |||
| 26718b5a3e | |||
| 7d27d3c8b1 | |||
| 0337bc1e92 | |||
| b4f4f9c304 | |||
| 5968ed74d5 | |||
| 50e2318f59 | |||
| 4c8f4ffcf3 | |||
| a3a0138526 | |||
| 7f57c2c3ee | |||
| f2a058b6b2 | |||
| 4d30363187 | |||
| 8fca01d6c7 | |||
| b75a905e2e | |||
| c3e992a0dd | |||
| 0599db101f | |||
| 872b6fdb59 | |||
| 274881e894 | |||
| 233dd4fe95 | |||
| a76d0719c9 | |||
| e98e59491d | |||
| 9b01aa0ff3 | |||
| 35b8a674ab | |||
| 658d3d9c49 | |||
| 6cf85a5b3e | |||
| 1cbfa90428 | |||
| 4715591545 | |||
| 70e6d50371 | |||
| e5d80bde23 | |||
| fd1c363352 | |||
| c3dbab896c | |||
| 7cf237b7b3 | |||
| 623661eb2c | |||
| 8787aa34a3 | |||
| 58cb970457 | |||
| e6539ad7e1 | |||
| 183641b842 | |||
| c0f13c6b9c | |||
| e6438eaabe | |||
| 97cb59894a | |||
| 233b3430ef | |||
| cc7ac1956b | |||
| 069118cbe9 | |||
| d6d655297b | |||
| c19259c84a | |||
| 210af50477 | |||
| 83d065e2f9 | |||
| f64fd63b6c | |||
| 38f126f2ef | |||
| 3a8d42f800 | |||
| 4a51b18ab8 | |||
| efe9abed60 | |||
| 12e3965325 | |||
| 20ea6e4fd8 | |||
| bd5f1b0240 | |||
| c395acaefc | |||
| 9d6dd335ae | |||
| e944f93ca3 | |||
| 99292f20ef | |||
| 4a471dd273 | |||
| 689aa96645 | |||
| e97b0ade55 | |||
| 5bc179afa8 | |||
| 301f323ee1 | |||
| 32bc9f5872 | |||
| 9c5c859da6 | |||
| c1a5d62a12 | |||
| 7aa0521ca4 | |||
| e0a82bc7a4 | |||
| 033e50a1c8 | |||
| 208d1f4836 | |||
| cb016580bd | |||
| 05dd8996a4 | |||
| 0dba56c55d | |||
| e81c55905c | |||
| 49e52fffb5 | |||
| 6f646101f2 | |||
| dd0662e639 | |||
| 284ee441f1 | |||
| db048dc4e9 | |||
| a3c33ce06a | |||
| afa1c5154f | |||
| 24d8d20ff8 | |||
| 6e62053754 | |||
| 6254bfde6f | |||
| f5760b1833 | |||
| 707c0436c5 | |||
| 29cb276a2a | |||
| d70683ea1f | |||
| a27bdecde0 | |||
| deb5cfddb2 | |||
| 6e657b2518 | |||
| 5547c2b4b8 | |||
| 8aedb36fac | |||
| 916b72d58d | |||
| af202716a2 | |||
| be5be0c3fe | |||
| 144b5cab58 | |||
| 4eee998cea | |||
| 02d189e18a | |||
| f413471c94 | |||
| 5c6865ce0d | |||
| f0e44249b7 | |||
| 7a1e2fc8f9 | |||
| f66e4d5a48 | |||
| 4e5bb350d1 | |||
| 2860552c94 | |||
| 42c811c112 | |||
| 8225340eec | |||
| 07e5488e0b | |||
| 819a61015f | |||
| 8c05cd838c | |||
| 311e8254d0 | |||
| 5ed8a68ab6 | |||
| 197577d951 | |||
| 7ab6355f17 | |||
| f7015d04dd |
342 changed files with 37389 additions and 452 deletions
4
.env
4
.env
|
|
@ -1 +1,5 @@
|
||||||
RAILS_LOG_TO_STDOUT=true
|
RAILS_LOG_TO_STDOUT=true
|
||||||
|
|
||||||
|
OPENAI_API_KEY="not-needed-for-local"
|
||||||
|
OPENAI_BASE_URL="http://192.168.1.246:8000/v1"
|
||||||
|
OPENAI_MODEL="cpatonn/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit"
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -59,3 +59,4 @@ config/application.yml
|
||||||
# Ignore AI Codebase-generated files
|
# Ignore AI Codebase-generated files
|
||||||
codebase.md
|
codebase.md
|
||||||
mise.toml
|
mise.toml
|
||||||
|
public/assets
|
||||||
|
|
|
||||||
23
app/blueprints/api/v1/artifact_blueprint.rb
Normal file
23
app/blueprints/api/v1/artifact_blueprint.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class ArtifactBlueprint < ApiBlueprint
|
||||||
|
field :name do |a|
|
||||||
|
{
|
||||||
|
en: a.name_en,
|
||||||
|
ja: a.name_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :granblue_id, :rarity
|
||||||
|
|
||||||
|
# Return proficiency as integer (nil for quirk artifacts)
|
||||||
|
field :proficiency do |a|
|
||||||
|
a.proficiency_before_type_cast
|
||||||
|
end
|
||||||
|
|
||||||
|
field :release_date, if: ->(_field, a, _options) { a.release_date.present? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
38
app/blueprints/api/v1/artifact_skill_blueprint.rb
Normal file
38
app/blueprints/api/v1/artifact_skill_blueprint.rb
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class ArtifactSkillBlueprint < ApiBlueprint
|
||||||
|
field :name do |s|
|
||||||
|
{
|
||||||
|
en: s.name_en,
|
||||||
|
ja: s.name_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_name do |s|
|
||||||
|
{
|
||||||
|
en: s.game_name_en,
|
||||||
|
ja: s.game_name_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :skill_group, :modifier, :polarity
|
||||||
|
|
||||||
|
field :base_values do |s|
|
||||||
|
s.base_values
|
||||||
|
end
|
||||||
|
|
||||||
|
field :growth, if: ->(_field, s, _options) { s.growth.present? } do |s|
|
||||||
|
s.growth.to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
field :suffix do |s|
|
||||||
|
{
|
||||||
|
en: s.suffix_en,
|
||||||
|
ja: s.suffix_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,7 +11,34 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
fields :granblue_id, :character_id, :rarity,
|
fields :granblue_id, :character_id, :rarity,
|
||||||
:element, :gender, :special
|
:element, :gender, :special, :season
|
||||||
|
|
||||||
|
field :season_name do |c|
|
||||||
|
c.season_name
|
||||||
|
end
|
||||||
|
|
||||||
|
field :series do |c|
|
||||||
|
# Use new lookup table if available
|
||||||
|
if c.character_series_records.any?
|
||||||
|
c.character_series_records.ordered.map do |cs|
|
||||||
|
{
|
||||||
|
id: cs.id,
|
||||||
|
slug: cs.slug,
|
||||||
|
name: {
|
||||||
|
en: cs.name_en,
|
||||||
|
ja: cs.name_jp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# Legacy fallback - return integer array
|
||||||
|
c.series
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
field :series_names do |c|
|
||||||
|
c.series_names
|
||||||
|
end
|
||||||
|
|
||||||
field :uncap do |c|
|
field :uncap do |c|
|
||||||
{
|
{
|
||||||
|
|
@ -38,6 +65,60 @@ module Api
|
||||||
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
|
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
field :nicknames do |c|
|
||||||
|
{
|
||||||
|
en: c.nicknames_en,
|
||||||
|
ja: c.nicknames_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
field :wiki do |c|
|
||||||
|
{
|
||||||
|
en: c.wiki_en,
|
||||||
|
ja: c.wiki_ja
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :gamewith, :kamigame
|
||||||
|
end
|
||||||
|
|
||||||
|
# Separate view for recruitment info - only include when needed (e.g., character detail page)
|
||||||
|
view :with_recruitment do
|
||||||
|
include_view :full
|
||||||
|
|
||||||
|
field :recruited_by do |c|
|
||||||
|
weapon = Weapon.find_by(recruits: c.granblue_id)
|
||||||
|
next nil unless weapon
|
||||||
|
|
||||||
|
{
|
||||||
|
id: weapon.id,
|
||||||
|
granblue_id: weapon.granblue_id,
|
||||||
|
name: {
|
||||||
|
en: weapon.name_en,
|
||||||
|
ja: weapon.name_jp
|
||||||
|
},
|
||||||
|
promotions: weapon.promotions,
|
||||||
|
promotion_names: weapon.promotion_names
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Separate view for raw data - only used by dedicated endpoint
|
||||||
|
view :raw do
|
||||||
|
excludes :name, :granblue_id, :character_id, :rarity, :element, :gender, :special, :uncap, :race, :proficiency
|
||||||
|
|
||||||
|
field :wiki_raw do |c|
|
||||||
|
c.wiki_raw
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_raw_en do |c|
|
||||||
|
c.game_raw_en
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_raw_jp do |c|
|
||||||
|
c.game_raw_jp
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
view :stats do
|
view :stats do
|
||||||
|
|
|
||||||
22
app/blueprints/api/v1/character_series_blueprint.rb
Normal file
22
app/blueprints/api/v1/character_series_blueprint.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CharacterSeriesBlueprint < ApiBlueprint
|
||||||
|
field :name do |cs|
|
||||||
|
{
|
||||||
|
en: cs.name_en,
|
||||||
|
ja: cs.name_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :slug, :order
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
field :character_count do |cs|
|
||||||
|
cs.characters.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
64
app/blueprints/api/v1/collection_artifact_blueprint.rb
Normal file
64
app/blueprints/api/v1/collection_artifact_blueprint.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionArtifactBlueprint < ApiBlueprint
|
||||||
|
identifier :id
|
||||||
|
|
||||||
|
fields :level, :nickname, :reroll_slot, :created_at, :updated_at
|
||||||
|
|
||||||
|
# Return element as integer
|
||||||
|
field :element do |obj|
|
||||||
|
obj.element_before_type_cast
|
||||||
|
end
|
||||||
|
|
||||||
|
# Proficiency is only present on quirk artifacts, return as integer
|
||||||
|
field :proficiency, if: ->(_field, obj, _options) { obj.proficiency.present? } do |obj|
|
||||||
|
obj.proficiency_before_type_cast
|
||||||
|
end
|
||||||
|
|
||||||
|
field :skills do |obj|
|
||||||
|
[
|
||||||
|
[obj.skill1, 1],
|
||||||
|
[obj.skill2, 2],
|
||||||
|
[obj.skill3, 3],
|
||||||
|
[obj.skill4, 4]
|
||||||
|
].map do |skill, slot|
|
||||||
|
next nil if skill.blank? || skill == {}
|
||||||
|
|
||||||
|
# Determine skill group based on slot
|
||||||
|
group = case slot
|
||||||
|
when 1, 2 then 1 # Group I
|
||||||
|
when 3 then 2 # Group II
|
||||||
|
when 4 then 3 # Group III
|
||||||
|
end
|
||||||
|
|
||||||
|
# Look up skill and compute strength from quality
|
||||||
|
modifier = skill['modifier']
|
||||||
|
quality = skill['quality'] || 1
|
||||||
|
level = skill['level'] || 1
|
||||||
|
|
||||||
|
artifact_skill = ArtifactSkill.find_skill(group, modifier)
|
||||||
|
strength = artifact_skill&.strength_for_quality(quality)
|
||||||
|
|
||||||
|
{
|
||||||
|
modifier: modifier,
|
||||||
|
strength: strength,
|
||||||
|
level: level
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include grade and recommendation by default
|
||||||
|
field :grade do |obj|
|
||||||
|
ArtifactGrader.new(obj).grade
|
||||||
|
end
|
||||||
|
|
||||||
|
association :artifact, blueprint: ArtifactBlueprint
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
association :artifact, blueprint: ArtifactBlueprint
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
26
app/blueprints/api/v1/collection_character_blueprint.rb
Normal file
26
app/blueprints/api/v1/collection_character_blueprint.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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 do |obj|
|
||||||
|
if obj.awakening.present?
|
||||||
|
{
|
||||||
|
type: AwakeningBlueprint.render_as_hash(obj.awakening),
|
||||||
|
level: obj.awakening_level
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
association :character, blueprint: CharacterBlueprint
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
association :character, blueprint: CharacterBlueprint, view: :full
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
11
app/blueprints/api/v1/collection_job_accessory_blueprint.rb
Normal file
11
app/blueprints/api/v1/collection_job_accessory_blueprint.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionJobAccessoryBlueprint < ApiBlueprint
|
||||||
|
identifier :id
|
||||||
|
|
||||||
|
fields :created_at, :updated_at
|
||||||
|
|
||||||
|
association :job_accessory, blueprint: JobAccessoryBlueprint
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
16
app/blueprints/api/v1/collection_summon_blueprint.rb
Normal file
16
app/blueprints/api/v1/collection_summon_blueprint.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionSummonBlueprint < ApiBlueprint
|
||||||
|
identifier :id
|
||||||
|
|
||||||
|
fields :uncap_level, :transcendence_step,
|
||||||
|
:created_at, :updated_at
|
||||||
|
|
||||||
|
association :summon, blueprint: SummonBlueprint
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
association :summon, blueprint: SummonBlueprint, view: :full
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
50
app/blueprints/api/v1/collection_weapon_blueprint.rb
Normal file
50
app/blueprints/api/v1/collection_weapon_blueprint.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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|
|
||||||
|
skills = []
|
||||||
|
if obj.ax_modifier1.present?
|
||||||
|
skills << {
|
||||||
|
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier1),
|
||||||
|
strength: obj.ax_strength1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if obj.ax_modifier2.present?
|
||||||
|
skills << {
|
||||||
|
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier2),
|
||||||
|
strength: obj.ax_strength2
|
||||||
|
}
|
||||||
|
end
|
||||||
|
skills
|
||||||
|
end
|
||||||
|
|
||||||
|
field :befoulment, if: ->(_, obj, _) { obj.befoulment_modifier.present? } do |obj|
|
||||||
|
{
|
||||||
|
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.befoulment_modifier),
|
||||||
|
strength: obj.befoulment_strength,
|
||||||
|
exorcism_level: obj.exorcism_level
|
||||||
|
}
|
||||||
|
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
|
||||||
49
app/blueprints/api/v1/crew_blueprint.rb
Normal file
49
app/blueprints/api/v1/crew_blueprint.rb
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewBlueprint < ApiBlueprint
|
||||||
|
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
|
||||||
|
|
||||||
|
view :minimal do
|
||||||
|
fields :name, :gamertag
|
||||||
|
end
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
|
||||||
|
|
||||||
|
field :member_count do |crew|
|
||||||
|
crew.active_memberships.count
|
||||||
|
end
|
||||||
|
|
||||||
|
field :captain do |crew|
|
||||||
|
captain = crew.captain
|
||||||
|
UserBlueprint.render_as_hash(captain, view: :minimal) if captain
|
||||||
|
end
|
||||||
|
|
||||||
|
field :vice_captains do |crew|
|
||||||
|
UserBlueprint.render_as_hash(crew.vice_captains, view: :minimal)
|
||||||
|
end
|
||||||
|
|
||||||
|
field :current_membership do |crew, options|
|
||||||
|
current_user = options[:current_user]
|
||||||
|
next nil unless current_user
|
||||||
|
|
||||||
|
membership = crew.crew_memberships.find_by(user_id: current_user.id, retired: false)
|
||||||
|
CrewMembershipBlueprint.render_as_hash(membership) if membership
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_members do
|
||||||
|
include_view :full
|
||||||
|
|
||||||
|
field :members do |crew|
|
||||||
|
CrewMembershipBlueprint.render_as_hash(
|
||||||
|
crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc),
|
||||||
|
view: :with_user
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
65
app/blueprints/api/v1/crew_gw_participation_blueprint.rb
Normal file
65
app/blueprints/api/v1/crew_gw_participation_blueprint.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewGwParticipationBlueprint < ApiBlueprint
|
||||||
|
fields :preliminary_ranking, :final_ranking
|
||||||
|
|
||||||
|
field :total_score do |participation|
|
||||||
|
participation.total_individual_honors
|
||||||
|
end
|
||||||
|
|
||||||
|
field :wins do |participation|
|
||||||
|
participation.wins_count
|
||||||
|
end
|
||||||
|
|
||||||
|
field :losses do |participation|
|
||||||
|
participation.losses_count
|
||||||
|
end
|
||||||
|
|
||||||
|
view :summary do
|
||||||
|
# summary uses base fields only (no gw_event)
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_event do
|
||||||
|
field :gw_event do |participation|
|
||||||
|
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_crew do
|
||||||
|
field :crew do |participation|
|
||||||
|
CrewBlueprint.render_as_hash(participation.crew, view: :minimal)
|
||||||
|
end
|
||||||
|
field :gw_event do |participation|
|
||||||
|
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
field :gw_event do |participation|
|
||||||
|
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||||
|
end
|
||||||
|
field :crew_scores do |participation|
|
||||||
|
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_individual_scores do
|
||||||
|
field :gw_event do |participation|
|
||||||
|
GwEventBlueprint.render_as_hash(participation.gw_event)
|
||||||
|
end
|
||||||
|
field :crew_scores do |participation|
|
||||||
|
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
|
||||||
|
end
|
||||||
|
field :individual_scores do |participation, options|
|
||||||
|
GwIndividualScoreBlueprint.render_as_hash(
|
||||||
|
participation.gw_individual_scores.includes(:crew_membership).order(:round),
|
||||||
|
view: :with_member,
|
||||||
|
current_user: options[:current_user]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
36
app/blueprints/api/v1/crew_invitation_blueprint.rb
Normal file
36
app/blueprints/api/v1/crew_invitation_blueprint.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewInvitationBlueprint < ApiBlueprint
|
||||||
|
fields :status, :expires_at, :created_at
|
||||||
|
|
||||||
|
view :default do
|
||||||
|
field :crew do |invitation|
|
||||||
|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_user do
|
||||||
|
field :user do |invitation|
|
||||||
|
UserBlueprint.render_as_hash(invitation.user, view: :minimal)
|
||||||
|
end
|
||||||
|
field :invited_by do |invitation|
|
||||||
|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
|
||||||
|
end
|
||||||
|
field :crew do |invitation|
|
||||||
|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :for_invitee do
|
||||||
|
field :crew do |invitation|
|
||||||
|
CrewBlueprint.render_as_hash(invitation.crew, view: :full)
|
||||||
|
end
|
||||||
|
field :invited_by do |invitation|
|
||||||
|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
25
app/blueprints/api/v1/crew_membership_blueprint.rb
Normal file
25
app/blueprints/api/v1/crew_membership_blueprint.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewMembershipBlueprint < ApiBlueprint
|
||||||
|
fields :role, :retired, :retired_at, :joined_at, :created_at
|
||||||
|
|
||||||
|
view :with_user do
|
||||||
|
fields :role, :retired, :retired_at, :joined_at, :created_at
|
||||||
|
|
||||||
|
field :user do |membership|
|
||||||
|
UserBlueprint.render_as_hash(membership.user, view: :minimal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_crew do
|
||||||
|
fields :role, :retired, :retired_at, :joined_at, :created_at
|
||||||
|
|
||||||
|
field :crew do |membership|
|
||||||
|
CrewBlueprint.render_as_hash(membership.crew, view: :minimal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
54
app/blueprints/api/v1/grid_artifact_blueprint.rb
Normal file
54
app/blueprints/api/v1/grid_artifact_blueprint.rb
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GridArtifactBlueprint < ApiBlueprint
|
||||||
|
fields :level, :reroll_slot, :orphaned
|
||||||
|
|
||||||
|
field :collection_artifact_id
|
||||||
|
field :out_of_sync, if: ->(_field, ga, _options) { ga.collection_artifact_id.present? } do |ga|
|
||||||
|
ga.out_of_sync?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return element as integer
|
||||||
|
field :element do |obj|
|
||||||
|
obj.element_before_type_cast
|
||||||
|
end
|
||||||
|
|
||||||
|
# Proficiency is only present on quirk artifacts, return as integer
|
||||||
|
field :proficiency, if: ->(_field, obj, _options) { obj.proficiency.present? } do |obj|
|
||||||
|
obj.proficiency_before_type_cast
|
||||||
|
end
|
||||||
|
|
||||||
|
field :skills do |obj|
|
||||||
|
[obj.skill1, obj.skill2, obj.skill3, obj.skill4].map do |skill|
|
||||||
|
next nil if skill.blank? || skill == {}
|
||||||
|
|
||||||
|
{
|
||||||
|
modifier: skill['modifier'],
|
||||||
|
strength: skill['strength'],
|
||||||
|
level: skill['level']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include grade and recommendation by default
|
||||||
|
field :grade do |obj|
|
||||||
|
ArtifactGrader.new(obj).grade
|
||||||
|
end
|
||||||
|
|
||||||
|
view :nested do
|
||||||
|
association :artifact, blueprint: ArtifactBlueprint
|
||||||
|
end
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
include_view :nested
|
||||||
|
association :grid_character, blueprint: GridCharacterBlueprint
|
||||||
|
end
|
||||||
|
|
||||||
|
view :destroyed do
|
||||||
|
fields :created_at, :updated_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -9,18 +9,25 @@ module Api
|
||||||
gc.transcendence_step
|
gc.transcendence_step
|
||||||
end
|
end
|
||||||
|
|
||||||
|
field :collection_character_id
|
||||||
|
field :out_of_sync, if: ->(_field, gc, _options) { gc.collection_character_id.present? } do |gc|
|
||||||
|
gc.out_of_sync?
|
||||||
|
end
|
||||||
|
|
||||||
view :preview do
|
view :preview do
|
||||||
association :character, name: :object, blueprint: CharacterBlueprint
|
association :character, blueprint: CharacterBlueprint
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested do
|
view :nested do
|
||||||
include_view :mastery_bonuses
|
include_view :mastery_bonuses
|
||||||
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
|
association :character, blueprint: CharacterBlueprint, view: :full
|
||||||
|
association :grid_artifact, blueprint: GridArtifactBlueprint, view: :nested,
|
||||||
|
if: ->(_field_name, gc, _options) { gc.grid_artifact.present? }
|
||||||
end
|
end
|
||||||
|
|
||||||
view :uncap do
|
view :uncap do
|
||||||
association :party, blueprint: PartyBlueprint
|
association :party, blueprint: PartyBlueprint
|
||||||
fields :position, :uncap_level
|
fields :position, :uncap_level, :transcendence_step
|
||||||
end
|
end
|
||||||
|
|
||||||
view :destroyed do
|
view :destroyed do
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class GridSummonBlueprint < ApiBlueprint
|
class GridSummonBlueprint < ApiBlueprint
|
||||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step, :orphaned
|
||||||
|
|
||||||
|
field :collection_summon_id
|
||||||
|
field :out_of_sync, if: ->(_field, gs, _options) { gs.collection_summon_id.present? } do |gs|
|
||||||
|
gs.out_of_sync?
|
||||||
|
end
|
||||||
|
|
||||||
view :preview do
|
view :preview do
|
||||||
association :summon, name: :object, blueprint: SummonBlueprint
|
association :summon, blueprint: SummonBlueprint
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested do
|
view :nested do
|
||||||
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
|
association :summon, blueprint: SummonBlueprint, view: :full
|
||||||
end
|
end
|
||||||
|
|
||||||
view :full do
|
view :full do
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,41 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class GridWeaponBlueprint < ApiBlueprint
|
class GridWeaponBlueprint < ApiBlueprint
|
||||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
fields :mainhand, :position, :uncap_level, :transcendence_step, :element, :orphaned
|
||||||
|
|
||||||
|
field :collection_weapon_id
|
||||||
|
field :out_of_sync, if: ->(_field, gw, _options) { gw.collection_weapon_id.present? } do |gw|
|
||||||
|
gw.out_of_sync?
|
||||||
|
end
|
||||||
|
|
||||||
view :preview do
|
view :preview do
|
||||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
association :weapon, blueprint: WeaponBlueprint
|
||||||
end
|
end
|
||||||
|
|
||||||
view :nested do
|
view :nested do
|
||||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w|
|
field :ax, if: ->(_field_name, w, _options) { w.ax_modifier1.present? } do |w|
|
||||||
[
|
skills = []
|
||||||
{ modifier: w.ax_modifier1, strength: w.ax_strength1 },
|
if w.ax_modifier1.present?
|
||||||
{ modifier: w.ax_modifier2, strength: w.ax_strength2 }
|
skills << {
|
||||||
]
|
modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier1),
|
||||||
|
strength: w.ax_strength1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if w.ax_modifier2.present?
|
||||||
|
skills << {
|
||||||
|
modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier2),
|
||||||
|
strength: w.ax_strength2
|
||||||
|
}
|
||||||
|
end
|
||||||
|
skills
|
||||||
|
end
|
||||||
|
|
||||||
|
field :befoulment, if: ->(_field_name, w, _options) { w.befoulment_modifier.present? } do |w|
|
||||||
|
{
|
||||||
|
modifier: WeaponStatModifierBlueprint.render_as_hash(w.befoulment_modifier),
|
||||||
|
strength: w.befoulment_strength,
|
||||||
|
exorcism_level: w.exorcism_level
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|
|
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|
|
||||||
|
|
@ -24,15 +47,15 @@ module Api
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
|
association :weapon, blueprint: WeaponBlueprint, view: :full,
|
||||||
if: ->(_field_name, w, _options) { w.weapon.present? }
|
if: ->(_field_name, w, _options) { w.weapon.present? }
|
||||||
|
|
||||||
association :weapon_keys,
|
association :weapon_keys,
|
||||||
blueprint: WeaponKeyBlueprint,
|
blueprint: WeaponKeyBlueprint,
|
||||||
if: ->(_field_name, w, _options) {
|
if: ->(_field_name, w, _options) {
|
||||||
w.weapon.present? &&
|
w.weapon.present? &&
|
||||||
w.weapon.series.present? &&
|
w.weapon.weapon_series.present? &&
|
||||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
w.weapon.weapon_series.has_weapon_keys
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -43,7 +66,7 @@ module Api
|
||||||
|
|
||||||
view :uncap do
|
view :uncap do
|
||||||
association :party, blueprint: PartyBlueprint
|
association :party, blueprint: PartyBlueprint
|
||||||
fields :position, :uncap_level
|
fields :position, :uncap_level, :transcendence_step
|
||||||
end
|
end
|
||||||
|
|
||||||
view :destroyed do
|
view :destroyed do
|
||||||
|
|
|
||||||
14
app/blueprints/api/v1/gw_crew_score_blueprint.rb
Normal file
14
app/blueprints/api/v1/gw_crew_score_blueprint.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GwCrewScoreBlueprint < ApiBlueprint
|
||||||
|
fields :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id, :victory
|
||||||
|
|
||||||
|
# Return round as integer value instead of enum string
|
||||||
|
field :round do |score|
|
||||||
|
GwCrewScore.rounds[score.round]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
34
app/blueprints/api/v1/gw_event_blueprint.rb
Normal file
34
app/blueprints/api/v1/gw_event_blueprint.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GwEventBlueprint < ApiBlueprint
|
||||||
|
fields :start_date, :end_date, :event_number
|
||||||
|
|
||||||
|
field :element do |event|
|
||||||
|
GwEvent.elements[event.element]
|
||||||
|
end
|
||||||
|
|
||||||
|
field :status do |event|
|
||||||
|
if event.active?
|
||||||
|
'active'
|
||||||
|
elsif event.upcoming?
|
||||||
|
'upcoming'
|
||||||
|
else
|
||||||
|
'finished'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include crew's total score if participation data is provided
|
||||||
|
field :crew_total_score, if: ->(_fn, event, options) { options[:participations]&.key?(event.id) } do |event, options|
|
||||||
|
options[:participations][event.id]&.total_individual_honors
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_participation do
|
||||||
|
field :participation, if: ->(_fn, _obj, options) { options[:participation].present? } do |_, options|
|
||||||
|
CrewGwParticipationBlueprint.render_as_hash(options[:participation], view: :summary)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
41
app/blueprints/api/v1/gw_individual_score_blueprint.rb
Normal file
41
app/blueprints/api/v1/gw_individual_score_blueprint.rb
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GwIndividualScoreBlueprint < ApiBlueprint
|
||||||
|
fields :round, :score, :is_cumulative, :excused
|
||||||
|
|
||||||
|
field :player_name do |score|
|
||||||
|
score.player_name
|
||||||
|
end
|
||||||
|
|
||||||
|
field :player_type do |score|
|
||||||
|
if score.crew_membership_id.present?
|
||||||
|
'member'
|
||||||
|
elsif score.phantom_player_id.present?
|
||||||
|
'phantom'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only return excuse_reason to crew officers
|
||||||
|
field :excuse_reason do |score, options|
|
||||||
|
current_user = options[:current_user]
|
||||||
|
score.excuse_reason if current_user&.crew_officer?
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_member do
|
||||||
|
field :member do |score|
|
||||||
|
if score.crew_membership.present?
|
||||||
|
CrewMembershipBlueprint.render_as_hash(score.crew_membership, view: :with_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
field :phantom do |score|
|
||||||
|
if score.phantom_player.present?
|
||||||
|
PhantomPlayerBlueprint.render_as_hash(score.phantom_player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -19,7 +19,7 @@ module Api
|
||||||
|
|
||||||
fields :granblue_id, :row, :order,
|
fields :granblue_id, :row, :order,
|
||||||
:master_level, :ultimate_mastery,
|
:master_level, :ultimate_mastery,
|
||||||
:accessory, :accessory_type
|
:accessory, :accessory_type, :aux_weapon
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ module Api
|
||||||
name: :job,
|
name: :job,
|
||||||
blueprint: JobBlueprint
|
blueprint: JobBlueprint
|
||||||
|
|
||||||
fields :slug, :color, :main, :base, :sub, :emp, :order
|
fields :slug, :color, :main, :base, :sub, :emp, :order, :image_id, :action_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ module Api
|
||||||
# Base fields that are always needed
|
# Base fields that are always needed
|
||||||
fields :local_id, :description, :shortcode, :visibility,
|
fields :local_id, :description, :shortcode, :visibility,
|
||||||
:name, :element, :extra, :charge_attack,
|
:name, :element, :extra, :charge_attack,
|
||||||
:button_count, :turn_count, :chain_count, :clear_time,
|
:button_count, :turn_count, :chain_count, :summon_count, :clear_time,
|
||||||
:full_auto, :auto_guard, :auto_summon,
|
:full_auto, :auto_guard, :auto_summon, :video_url,
|
||||||
:created_at, :updated_at
|
:created_at, :updated_at
|
||||||
|
|
||||||
fields :local_id, :description, :charge_attack,
|
fields :local_id, :description, :charge_attack,
|
||||||
:button_count, :turn_count, :chain_count,
|
:button_count, :turn_count, :chain_count, :summon_count,
|
||||||
:master_level, :ultimate_mastery
|
:master_level, :ultimate_mastery
|
||||||
|
|
||||||
# Party associations
|
# Party associations
|
||||||
|
|
@ -28,8 +28,17 @@ module Api
|
||||||
|
|
||||||
# Metadata associations
|
# Metadata associations
|
||||||
field :favorited do |party, options|
|
field :favorited do |party, options|
|
||||||
|
# Use preloaded favorite_party_ids if available, otherwise fall back to query
|
||||||
|
if options[:favorite_party_ids]
|
||||||
|
options[:favorite_party_ids].include?(party.id)
|
||||||
|
else
|
||||||
party.favorited?(options[:current_user])
|
party.favorited?(options[:current_user])
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
field :has_orphaned_items do |party|
|
||||||
|
party.has_orphaned_items?
|
||||||
|
end
|
||||||
|
|
||||||
# For collection views
|
# For collection views
|
||||||
view :preview do
|
view :preview do
|
||||||
|
|
|
||||||
40
app/blueprints/api/v1/phantom_player_blueprint.rb
Normal file
40
app/blueprints/api/v1/phantom_player_blueprint.rb
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PhantomPlayerBlueprint < ApiBlueprint
|
||||||
|
fields :name, :granblue_id, :notes, :claim_confirmed, :retired, :retired_at, :joined_at
|
||||||
|
|
||||||
|
field :claimed do |phantom|
|
||||||
|
phantom.claimed_by_id.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_claimed_by do
|
||||||
|
field :claimed_by do |phantom|
|
||||||
|
phantom.claimed_by ? UserBlueprint.render_as_hash(phantom.claimed_by, view: :minimal) : nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
view :with_scores do
|
||||||
|
include_view :with_claimed_by
|
||||||
|
|
||||||
|
field :total_score do |phantom|
|
||||||
|
phantom.gw_individual_scores.sum(:score)
|
||||||
|
end
|
||||||
|
|
||||||
|
field :score_count do |phantom|
|
||||||
|
phantom.gw_individual_scores.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Used for pending phantom claims - includes crew info for context
|
||||||
|
view :with_crew do
|
||||||
|
include_view :with_claimed_by
|
||||||
|
|
||||||
|
field :crew do |phantom|
|
||||||
|
phantom.crew ? CrewBlueprint.render_as_hash(phantom.crew, view: :minimal) : nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,8 @@ module Api
|
||||||
module V1
|
module V1
|
||||||
class RaidBlueprint < ApiBlueprint
|
class RaidBlueprint < ApiBlueprint
|
||||||
view :nested do
|
view :nested do
|
||||||
|
identifier :id
|
||||||
|
|
||||||
field :name do |raid|
|
field :name do |raid|
|
||||||
{
|
{
|
||||||
en: raid.name_en,
|
en: raid.name_en,
|
||||||
|
|
@ -18,7 +20,6 @@ module Api
|
||||||
|
|
||||||
view :full do
|
view :full do
|
||||||
include_view :nested
|
include_view :nested
|
||||||
association :group, blueprint: RaidGroupBlueprint, view: :flat
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ module Api
|
||||||
module V1
|
module V1
|
||||||
class RaidGroupBlueprint < ApiBlueprint
|
class RaidGroupBlueprint < ApiBlueprint
|
||||||
view :flat do
|
view :flat do
|
||||||
|
identifier :id
|
||||||
|
|
||||||
field :name do |group|
|
field :name do |group|
|
||||||
{
|
{
|
||||||
en: group.name_en,
|
en: group.name_en,
|
||||||
|
|
@ -11,7 +13,7 @@ module Api
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
fields :difficulty, :order, :section, :extra, :guidebooks, :hl
|
fields :difficulty, :order, :section, :extra, :guidebooks, :hl, :unlimited
|
||||||
end
|
end
|
||||||
|
|
||||||
view :full do
|
view :full do
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,27 @@ module Api
|
||||||
class SearchBlueprint < Blueprinter::Base
|
class SearchBlueprint < Blueprinter::Base
|
||||||
identifier :searchable_id
|
identifier :searchable_id
|
||||||
fields :searchable_type, :granblue_id, :name_en, :name_jp, :element
|
fields :searchable_type, :granblue_id, :name_en, :name_jp, :element
|
||||||
|
|
||||||
|
# Character-specific fields (nil for non-characters)
|
||||||
|
field :season do |document|
|
||||||
|
document.searchable_type == 'Character' ? document.searchable&.season : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
field :series do |document|
|
||||||
|
next nil unless document.searchable_type == 'Character'
|
||||||
|
|
||||||
|
character = document.searchable
|
||||||
|
next nil unless character
|
||||||
|
|
||||||
|
# Return series as array of objects with id, slug, and name
|
||||||
|
character.character_series_records.ordered.map do |series|
|
||||||
|
{
|
||||||
|
id: series.id,
|
||||||
|
slug: series.slug,
|
||||||
|
name: { en: series.name_en, ja: series.name_jp }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,24 @@ module Api
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
fields :granblue_id, :element, :rarity, :max_level
|
fields :granblue_id, :element, :rarity, :max_level, :promotions
|
||||||
|
|
||||||
|
field :promotion_names do |s|
|
||||||
|
s.promotion_names
|
||||||
|
end
|
||||||
|
|
||||||
|
field :series do |s|
|
||||||
|
if s.summon_series.present?
|
||||||
|
{
|
||||||
|
id: s.summon_series_id,
|
||||||
|
slug: s.summon_series.slug,
|
||||||
|
name: {
|
||||||
|
en: s.summon_series.name_en,
|
||||||
|
ja: s.summon_series.name_jp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
field :uncap do |s|
|
field :uncap do |s|
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +69,39 @@ module Api
|
||||||
view :full do
|
view :full do
|
||||||
include_view :stats
|
include_view :stats
|
||||||
include_view :dates
|
include_view :dates
|
||||||
|
|
||||||
|
field :nicknames do |s|
|
||||||
|
{
|
||||||
|
en: s.nicknames_en,
|
||||||
|
ja: s.nicknames_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
field :wiki do |s|
|
||||||
|
{
|
||||||
|
en: s.wiki_en,
|
||||||
|
ja: s.wiki_ja
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :gamewith, :kamigame
|
||||||
|
end
|
||||||
|
|
||||||
|
# Separate view for raw data - only used by dedicated endpoint
|
||||||
|
view :raw do
|
||||||
|
excludes :name, :granblue_id, :element, :rarity, :max_level, :uncap
|
||||||
|
|
||||||
|
field :wiki_raw do |s|
|
||||||
|
s.wiki_raw
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_raw_en do |s|
|
||||||
|
s.game_raw_en
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_raw_jp do |s|
|
||||||
|
s.game_raw_jp
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
22
app/blueprints/api/v1/summon_series_blueprint.rb
Normal file
22
app/blueprints/api/v1/summon_series_blueprint.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class SummonSeriesBlueprint < ApiBlueprint
|
||||||
|
field :name do |ss|
|
||||||
|
{
|
||||||
|
en: ss.name_en,
|
||||||
|
ja: ss.name_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :slug, :order
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
field :summon_count do |ss|
|
||||||
|
ss.summons.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,13 +4,23 @@ module Api
|
||||||
module V1
|
module V1
|
||||||
class UserBlueprint < ApiBlueprint
|
class UserBlueprint < ApiBlueprint
|
||||||
view :minimal do
|
view :minimal do
|
||||||
fields :username, :language, :private, :gender, :theme, :role
|
fields :username, :language, :private, :gender, :theme, :role, :granblue_id, :show_gamertag, :show_granblue_id
|
||||||
|
# Return collection_privacy as integer (enum returns string by default)
|
||||||
|
field :collection_privacy do |user|
|
||||||
|
User.collection_privacies[user.collection_privacy]
|
||||||
|
end
|
||||||
field :avatar do |user|
|
field :avatar do |user|
|
||||||
{
|
{
|
||||||
picture: user.picture,
|
picture: user.picture,
|
||||||
element: user.element
|
element: user.element
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
# Use preloaded active_crew_membership to avoid N+1
|
||||||
|
field :gamertag, if: ->(_, user, _) {
|
||||||
|
user.show_gamertag && user.active_crew_membership&.crew&.gamertag.present?
|
||||||
|
} do |user|
|
||||||
|
user.active_crew_membership.crew.gamertag
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
view :profile do
|
view :profile do
|
||||||
|
|
@ -25,7 +35,9 @@ module Api
|
||||||
fields :username, :token
|
fields :username, :token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Settings view includes all user data + email (only for authenticated user viewing own settings)
|
||||||
view :settings do
|
view :settings do
|
||||||
|
include_view :minimal
|
||||||
fields :email
|
fields :email
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,41 @@ module Api
|
||||||
# Primary information
|
# Primary information
|
||||||
fields :granblue_id, :element, :proficiency,
|
fields :granblue_id, :element, :proficiency,
|
||||||
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
|
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
|
||||||
:series, :ax, :ax_type
|
:ax, :ax_type, :gacha, :promotions, :forge_order, :extra
|
||||||
|
|
||||||
|
# Series - returns full object with flags if weapon_series is present, fallback to legacy integer
|
||||||
|
field :series do |w|
|
||||||
|
if w.weapon_series.present?
|
||||||
|
{
|
||||||
|
id: w.weapon_series_id,
|
||||||
|
slug: w.weapon_series.slug,
|
||||||
|
name: {
|
||||||
|
en: w.weapon_series.name_en,
|
||||||
|
ja: w.weapon_series.name_jp
|
||||||
|
},
|
||||||
|
has_weapon_keys: w.weapon_series.has_weapon_keys,
|
||||||
|
has_awakening: w.weapon_series.has_awakening,
|
||||||
|
augment_type: w.weapon_series.augment_type,
|
||||||
|
extra: w.weapon_series.extra,
|
||||||
|
element_changeable: w.weapon_series.element_changeable
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# Legacy fallback for backwards compatibility
|
||||||
|
w.series
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
field :promotion_names do |w|
|
||||||
|
w.promotion_names
|
||||||
|
end
|
||||||
|
|
||||||
# Uncap information
|
# Uncap information
|
||||||
field :uncap do |w|
|
field :uncap do |w|
|
||||||
{
|
{
|
||||||
flb: w.flb,
|
flb: w.flb,
|
||||||
ulb: w.ulb,
|
ulb: w.ulb,
|
||||||
transcendence: w.transcendence
|
transcendence: w.transcendence,
|
||||||
|
extra_prerequisite: w.extra_prerequisite
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -57,6 +84,89 @@ module Api
|
||||||
association :awakenings,
|
association :awakenings,
|
||||||
blueprint: AwakeningBlueprint,
|
blueprint: AwakeningBlueprint,
|
||||||
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
|
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
|
||||||
|
|
||||||
|
field :nicknames do |w|
|
||||||
|
{
|
||||||
|
en: w.nicknames_en,
|
||||||
|
ja: w.nicknames_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
field :wiki do |w|
|
||||||
|
{
|
||||||
|
en: w.wiki_en,
|
||||||
|
ja: w.wiki_ja
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :gamewith, :kamigame
|
||||||
|
|
||||||
|
field :recruits do |w|
|
||||||
|
next nil unless w.recruits.present?
|
||||||
|
|
||||||
|
character = Character.find_by(granblue_id: w.recruits)
|
||||||
|
next nil unless character
|
||||||
|
|
||||||
|
{
|
||||||
|
id: character.id,
|
||||||
|
granblue_id: character.granblue_id,
|
||||||
|
name: {
|
||||||
|
en: character.name_en,
|
||||||
|
ja: character.name_jp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Forge chain fields
|
||||||
|
field :forged_from do |w|
|
||||||
|
next nil unless w.forged_from.present?
|
||||||
|
|
||||||
|
parent = w.forged_from_weapon
|
||||||
|
next nil unless parent
|
||||||
|
|
||||||
|
{
|
||||||
|
id: parent.id,
|
||||||
|
granblue_id: parent.granblue_id,
|
||||||
|
name: {
|
||||||
|
en: parent.name_en,
|
||||||
|
ja: parent.name_jp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
field :forge_chain do |w|
|
||||||
|
next nil unless w.forge_chain_id.present?
|
||||||
|
|
||||||
|
w.forge_chain.map do |weapon|
|
||||||
|
{
|
||||||
|
id: weapon.id,
|
||||||
|
granblue_id: weapon.granblue_id,
|
||||||
|
name: {
|
||||||
|
en: weapon.name_en,
|
||||||
|
ja: weapon.name_jp
|
||||||
|
},
|
||||||
|
forge_order: weapon.forge_order
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Separate view for raw data - only used by dedicated endpoint
|
||||||
|
view :raw do
|
||||||
|
excludes :name, :granblue_id, :element, :proficiency, :max_level, :max_skill_level,
|
||||||
|
:max_awakening_level, :limit, :rarity, :series, :ax, :ax_type, :uncap
|
||||||
|
|
||||||
|
field :wiki_raw do |w|
|
||||||
|
w.wiki_raw
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_raw_en do |w|
|
||||||
|
w.game_raw_en
|
||||||
|
end
|
||||||
|
|
||||||
|
field :game_raw_jp do |w|
|
||||||
|
w.game_raw_jp
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
23
app/blueprints/api/v1/weapon_series_blueprint.rb
Normal file
23
app/blueprints/api/v1/weapon_series_blueprint.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class WeaponSeriesBlueprint < ApiBlueprint
|
||||||
|
field :name do |ws|
|
||||||
|
{
|
||||||
|
en: ws.name_en,
|
||||||
|
ja: ws.name_jp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
fields :slug, :order, :extra, :element_changeable, :has_weapon_keys,
|
||||||
|
:has_awakening, :augment_type
|
||||||
|
|
||||||
|
view :full do
|
||||||
|
field :weapon_count do |ws|
|
||||||
|
ws.weapons.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
10
app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb
Normal file
10
app/blueprints/api/v1/weapon_stat_modifier_blueprint.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class WeaponStatModifierBlueprint < Blueprinter::Base
|
||||||
|
identifier :id
|
||||||
|
fields :slug, :name_en, :name_jp, :category, :stat, :polarity, :suffix
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -9,8 +9,18 @@ module Api
|
||||||
##### Constants
|
##### Constants
|
||||||
COLLECTION_PER_PAGE = 15
|
COLLECTION_PER_PAGE = 15
|
||||||
SEARCH_PER_PAGE = 10
|
SEARCH_PER_PAGE = 10
|
||||||
|
MAX_PER_PAGE = 100
|
||||||
|
MIN_PER_PAGE = 1
|
||||||
|
|
||||||
##### Errors
|
##### Errors
|
||||||
|
# Catch-all for unhandled exceptions - log details and return 500
|
||||||
|
# NOTE: Must be defined FIRST so it's checked LAST (Rails matches bottom-to-top)
|
||||||
|
rescue_from StandardError do |e|
|
||||||
|
Rails.logger.error "[500 Error] #{e.class}: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace&.first(20)&.join("\n")
|
||||||
|
render json: { error: 'Internal Server Error', message: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response
|
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response
|
||||||
rescue_from ActiveRecord::RecordNotDestroyed, with: :render_unprocessable_entity_response
|
rescue_from ActiveRecord::RecordNotDestroyed, with: :render_unprocessable_entity_response
|
||||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response_without_object
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response_without_object
|
||||||
|
|
@ -24,10 +34,24 @@ module Api
|
||||||
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
|
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
|
||||||
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response
|
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response
|
||||||
|
|
||||||
|
# Collection errors
|
||||||
|
rescue_from CollectionErrors::CollectionError do |e|
|
||||||
|
render json: e.to_hash, status: e.http_status
|
||||||
|
end
|
||||||
|
|
||||||
|
# Crew errors
|
||||||
|
rescue_from CrewErrors::CrewError do |e|
|
||||||
|
render json: e.to_hash, status: e.http_status
|
||||||
|
end
|
||||||
|
|
||||||
rescue_from GranblueError do |e|
|
rescue_from GranblueError do |e|
|
||||||
render_error(e)
|
render_error(e)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
rescue_from Api::V1::GranblueError do |e|
|
||||||
|
render_error(e)
|
||||||
|
end
|
||||||
|
|
||||||
##### Hooks
|
##### Hooks
|
||||||
before_action :current_user
|
before_action :current_user
|
||||||
before_action :default_content_type
|
before_action :default_content_type
|
||||||
|
|
@ -86,7 +110,17 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_unprocessable_entity_response(exception)
|
def render_unprocessable_entity_response(exception)
|
||||||
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
|
error_data = if exception.respond_to?(:to_hash)
|
||||||
|
exception.to_hash
|
||||||
|
elsif exception.is_a?(ActionController::ParameterMissing)
|
||||||
|
{ message: exception.message, param: exception.param }
|
||||||
|
elsif exception.respond_to?(:message)
|
||||||
|
{ message: exception.message }
|
||||||
|
else
|
||||||
|
exception
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: ErrorBlueprint.render_as_json(nil, errors: error_data),
|
||||||
status: :unprocessable_entity
|
status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -121,12 +155,41 @@ module Api
|
||||||
raise UnauthorizedError unless current_user
|
raise UnauthorizedError unless current_user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the requested page size within valid bounds
|
||||||
|
# Falls back to default if not specified or invalid
|
||||||
|
# Reads from X-Per-Page header
|
||||||
|
def page_size(default = COLLECTION_PER_PAGE)
|
||||||
|
per_page_header = request.headers['X-Per-Page']
|
||||||
|
return default unless per_page_header.present?
|
||||||
|
|
||||||
|
requested_size = per_page_header.to_i
|
||||||
|
return default if requested_size <= 0
|
||||||
|
|
||||||
|
[[requested_size, MAX_PER_PAGE].min, MIN_PER_PAGE].max
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the requested page size for search operations
|
||||||
|
def search_page_size
|
||||||
|
page_size(SEARCH_PER_PAGE)
|
||||||
|
end
|
||||||
|
|
||||||
def n_plus_one_detection
|
def n_plus_one_detection
|
||||||
Prosopite.scan
|
Prosopite.scan
|
||||||
yield
|
yield
|
||||||
ensure
|
ensure
|
||||||
Prosopite.finish
|
Prosopite.finish
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns pagination metadata for will_paginate collections
|
||||||
|
# @param collection [ActiveRecord::Relation] Paginated collection using will_paginate
|
||||||
|
# @return [Hash] Pagination metadata with count, total_pages, and per_page
|
||||||
|
def pagination_meta(collection)
|
||||||
|
{
|
||||||
|
count: collection.total_entries,
|
||||||
|
total_pages: collection.total_pages,
|
||||||
|
per_page: collection.limit_value || collection.per_page
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
70
app/controllers/api/v1/artifact_skills_controller.rb
Normal file
70
app/controllers/api/v1/artifact_skills_controller.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class ArtifactSkillsController < Api::V1::ApiController
|
||||||
|
before_action :set_artifact_skill, only: %w[show update]
|
||||||
|
before_action :ensure_editor_role, only: %w[update]
|
||||||
|
|
||||||
|
# GET /artifact_skills
|
||||||
|
def index
|
||||||
|
@skills = ArtifactSkill.all
|
||||||
|
@skills = @skills.where(skill_group: params[:group]) if params[:group].present?
|
||||||
|
@skills = @skills.where(polarity: params[:polarity]) if params[:polarity].present?
|
||||||
|
|
||||||
|
render json: ArtifactSkillBlueprint.render(@skills, root: :artifact_skills)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /artifact_skills/for_slot/:slot
|
||||||
|
# Returns skills valid for a specific slot (1-4)
|
||||||
|
def for_slot
|
||||||
|
slot = params[:slot].to_i
|
||||||
|
|
||||||
|
unless (1..4).cover?(slot)
|
||||||
|
return render json: { error: 'Slot must be between 1 and 4' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
@skills = ArtifactSkill.for_slot(slot)
|
||||||
|
render json: ArtifactSkillBlueprint.render(@skills, root: :artifact_skills)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /artifact_skills/:id
|
||||||
|
def show
|
||||||
|
render json: ArtifactSkillBlueprint.render(@skill)
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /artifact_skills/:id
|
||||||
|
def update
|
||||||
|
if @skill.update(artifact_skill_params)
|
||||||
|
ArtifactSkill.clear_cache!
|
||||||
|
render json: ArtifactSkillBlueprint.render(@skill)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@skill)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_artifact_skill
|
||||||
|
@skill = ArtifactSkill.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def artifact_skill_params
|
||||||
|
params.permit(
|
||||||
|
:skill_group, :modifier,
|
||||||
|
:name_en, :name_jp,
|
||||||
|
:game_name_en, :game_name_jp,
|
||||||
|
:suffix_en, :suffix_jp,
|
||||||
|
:growth, :polarity,
|
||||||
|
base_values: []
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
118
app/controllers/api/v1/artifacts_controller.rb
Normal file
118
app/controllers/api/v1/artifacts_controller.rb
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class ArtifactsController < Api::V1::ApiController
|
||||||
|
before_action :set_artifact, only: %i[show download_image download_images download_status]
|
||||||
|
|
||||||
|
# GET /artifacts
|
||||||
|
def index
|
||||||
|
@artifacts = Artifact.all
|
||||||
|
@artifacts = @artifacts.where(rarity: params[:rarity]) if params[:rarity].present?
|
||||||
|
@artifacts = @artifacts.where(proficiency: params[:proficiency]) if params[:proficiency].present?
|
||||||
|
|
||||||
|
render json: ArtifactBlueprint.render(@artifacts, root: :artifacts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /artifacts/:id
|
||||||
|
def show
|
||||||
|
render json: ArtifactBlueprint.render(@artifact)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /artifacts/grade
|
||||||
|
# Grades artifact skills without persisting. Accepts skill data and returns grade/recommendation.
|
||||||
|
#
|
||||||
|
# @param artifact_id [String] Optional - ID of base artifact (for quirk detection)
|
||||||
|
# @param skill1 [Hash] Skill data with modifier, strength, level
|
||||||
|
# @param skill2 [Hash] Skill data with modifier, strength, level
|
||||||
|
# @param skill3 [Hash] Skill data with modifier, strength, level
|
||||||
|
# @param skill4 [Hash] Skill data with modifier, strength, level
|
||||||
|
def grade
|
||||||
|
artifact_data = build_gradeable_artifact
|
||||||
|
grader = ArtifactGrader.new(artifact_data)
|
||||||
|
|
||||||
|
render json: { grade: grader.grade }
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /artifacts/:id/download_image
|
||||||
|
# Synchronously downloads a single image size for the artifact
|
||||||
|
#
|
||||||
|
# @param size [String] Required - 'square' or 'wide'
|
||||||
|
# @param force [Boolean] Optional - Force re-download even if exists
|
||||||
|
def download_image
|
||||||
|
size = params[:size]
|
||||||
|
force = params[:force] == true || params[:force] == 'true'
|
||||||
|
|
||||||
|
unless %w[square wide].include?(size)
|
||||||
|
return render json: { error: "Invalid size. Must be 'square' or 'wide'" }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = ArtifactImageDownloadService.new(@artifact, force: force, size: size, storage: :s3)
|
||||||
|
result = service.download
|
||||||
|
|
||||||
|
if result.success?
|
||||||
|
render json: { success: true, images: result.images }
|
||||||
|
else
|
||||||
|
render json: { success: false, error: result.error }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /artifacts/:id/download_images
|
||||||
|
# Asynchronously downloads all images for the artifact via background job
|
||||||
|
#
|
||||||
|
# @param options.force [Boolean] Optional - Force re-download even if exists
|
||||||
|
# @param options.size [String] Optional - 'square', 'wide', or 'all' (default)
|
||||||
|
def download_images
|
||||||
|
options = params[:options] || {}
|
||||||
|
force = options[:force] == true || options[:force] == 'true'
|
||||||
|
size = options[:size] || 'all'
|
||||||
|
|
||||||
|
DownloadArtifactImagesJob.perform_later(@artifact.id, force: force, size: size)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: 'queued',
|
||||||
|
message: "Image download queued for artifact #{@artifact.granblue_id}",
|
||||||
|
artifact_id: @artifact.id
|
||||||
|
}, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /artifacts/:id/download_status
|
||||||
|
# Returns the current status of a background download job
|
||||||
|
def download_status
|
||||||
|
status = DownloadArtifactImagesJob.status(@artifact.id)
|
||||||
|
render json: status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_artifact
|
||||||
|
@artifact = Artifact.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render_not_found_response('artifact')
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_gradeable_artifact
|
||||||
|
base_artifact = params[:artifact_id].present? ? Artifact.find_by(id: params[:artifact_id]) : nil
|
||||||
|
|
||||||
|
# Build a simple struct that responds to what ArtifactGrader needs
|
||||||
|
OpenStruct.new(
|
||||||
|
skill1: grade_params[:skill1] || {},
|
||||||
|
skill2: grade_params[:skill2] || {},
|
||||||
|
skill3: grade_params[:skill3] || {},
|
||||||
|
skill4: grade_params[:skill4] || {},
|
||||||
|
artifact: base_artifact || OpenStruct.new(quirk?: false)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def grade_params
|
||||||
|
params.permit(
|
||||||
|
:artifact_id,
|
||||||
|
skill1: %i[modifier strength level],
|
||||||
|
skill2: %i[modifier strength level],
|
||||||
|
skill3: %i[modifier strength level],
|
||||||
|
skill4: %i[modifier strength level]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
72
app/controllers/api/v1/character_series_controller.rb
Normal file
72
app/controllers/api/v1/character_series_controller.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CharacterSeriesController < Api::V1::ApiController
|
||||||
|
before_action :set_character_series, only: %i[show update destroy]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update destroy]
|
||||||
|
|
||||||
|
# GET /character_series
|
||||||
|
def index
|
||||||
|
character_series = CharacterSeries.ordered
|
||||||
|
render json: CharacterSeriesBlueprint.render(character_series)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /character_series/:id
|
||||||
|
def show
|
||||||
|
render json: CharacterSeriesBlueprint.render(@character_series, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /character_series
|
||||||
|
def create
|
||||||
|
character_series = CharacterSeries.new(character_series_params)
|
||||||
|
|
||||||
|
if character_series.save
|
||||||
|
render json: CharacterSeriesBlueprint.render(character_series, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(character_series)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /character_series/:id
|
||||||
|
def update
|
||||||
|
if @character_series.update(character_series_params)
|
||||||
|
render json: CharacterSeriesBlueprint.render(@character_series, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@character_series)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /character_series/:id
|
||||||
|
def destroy
|
||||||
|
if @character_series.characters.exists?
|
||||||
|
render json: ErrorBlueprint.render(nil, error: {
|
||||||
|
message: 'Cannot delete series with associated characters',
|
||||||
|
code: 'has_dependencies'
|
||||||
|
}), status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
@character_series.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_character_series
|
||||||
|
# Support lookup by slug or UUID
|
||||||
|
@character_series = CharacterSeries.find_by(slug: params[:id]) || CharacterSeries.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[CHARACTER_SERIES] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def character_series_params
|
||||||
|
params.require(:character_series).permit(:name_en, :name_jp, :slug, :order)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,16 +3,237 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class CharactersController < Api::V1::ApiController
|
class CharactersController < Api::V1::ApiController
|
||||||
before_action :set
|
include IdResolvable
|
||||||
|
include BatchPreviewable
|
||||||
|
|
||||||
|
before_action :set, only: %i[show related download_image download_images download_status update raw fetch_wiki]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
|
||||||
|
|
||||||
|
# GET /characters/:id
|
||||||
def show
|
def show
|
||||||
render json: CharacterBlueprint.render(@character)
|
render json: CharacterBlueprint.render(@character, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /characters/:id/related
|
||||||
|
def related
|
||||||
|
return render json: [] unless @character.character_id
|
||||||
|
|
||||||
|
related = Character.where(character_id: @character.character_id)
|
||||||
|
.where.not(id: @character.id)
|
||||||
|
render json: CharacterBlueprint.render(related)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /characters
|
||||||
|
# Creates a new character record
|
||||||
|
def create
|
||||||
|
character = Character.new(character_params)
|
||||||
|
|
||||||
|
if character.save
|
||||||
|
render json: CharacterBlueprint.render(character, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(character)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /characters/:id
|
||||||
|
# Updates an existing character record
|
||||||
|
def update
|
||||||
|
if @character.update(character_params)
|
||||||
|
render json: CharacterBlueprint.render(@character, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@character)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /characters/validate/:granblue_id
|
||||||
|
# Validates that a granblue_id has accessible images on Granblue servers
|
||||||
|
def validate
|
||||||
|
granblue_id = params[:granblue_id]
|
||||||
|
validator = CharacterImageValidator.new(granblue_id)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
granblue_id: granblue_id,
|
||||||
|
exists_in_db: validator.exists_in_db?
|
||||||
|
}
|
||||||
|
|
||||||
|
if validator.valid?
|
||||||
|
render json: response_data.merge(
|
||||||
|
valid: true,
|
||||||
|
image_urls: validator.image_urls
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render json: response_data.merge(
|
||||||
|
valid: false,
|
||||||
|
error: validator.error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /characters/:id/download_image
|
||||||
|
# Synchronously downloads a single image for a character
|
||||||
|
def download_image
|
||||||
|
size = params[:size]
|
||||||
|
transformation = params[:transformation]
|
||||||
|
force = params[:force] == true
|
||||||
|
|
||||||
|
# Validate size
|
||||||
|
valid_sizes = Granblue::Downloaders::CharacterDownloader::SIZES
|
||||||
|
unless valid_sizes.include?(size)
|
||||||
|
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate transformation for characters (01, 02, 03, 04)
|
||||||
|
valid_transformations = %w[01 02 03 04]
|
||||||
|
if transformation.present? && !valid_transformations.include?(transformation)
|
||||||
|
return render json: { error: "Invalid transformation. Must be one of: #{valid_transformations.join(', ')}" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build variant ID
|
||||||
|
variant_id = transformation.present? ? "#{@character.granblue_id}_#{transformation}" : "#{@character.granblue_id}_01"
|
||||||
|
|
||||||
|
begin
|
||||||
|
downloader = Granblue::Downloaders::CharacterDownloader.new(
|
||||||
|
@character.granblue_id,
|
||||||
|
storage: :s3,
|
||||||
|
force: force,
|
||||||
|
verbose: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the download_variant method directly for a single variant/size
|
||||||
|
downloader.send(:download_variant, variant_id, size)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
character_id: @character.id,
|
||||||
|
granblue_id: @character.granblue_id,
|
||||||
|
size: size,
|
||||||
|
transformation: transformation,
|
||||||
|
message: 'Image downloaded successfully'
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[CHARACTERS] Image download error for #{@character.id}: #{e.message}"
|
||||||
|
render json: { success: false, error: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /characters/:id/download_images
|
||||||
|
# Triggers async image download for a character
|
||||||
|
def download_images
|
||||||
|
# Queue the download job
|
||||||
|
DownloadCharacterImagesJob.perform_later(
|
||||||
|
@character.id,
|
||||||
|
force: params.dig(:options, :force) == true,
|
||||||
|
size: params.dig(:options, :size) || 'all'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set initial status
|
||||||
|
DownloadCharacterImagesJob.update_status(
|
||||||
|
@character.id,
|
||||||
|
'queued',
|
||||||
|
progress: 0,
|
||||||
|
images_downloaded: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: 'queued',
|
||||||
|
character_id: @character.id,
|
||||||
|
granblue_id: @character.granblue_id,
|
||||||
|
message: 'Image download job has been queued'
|
||||||
|
}, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /characters/:id/download_status
|
||||||
|
# Returns the status of an image download job
|
||||||
|
def download_status
|
||||||
|
status = DownloadCharacterImagesJob.status(@character.id)
|
||||||
|
|
||||||
|
render json: status.merge(
|
||||||
|
character_id: @character.id,
|
||||||
|
granblue_id: @character.granblue_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /characters/:id/raw
|
||||||
|
# Returns raw wiki and game data for database viewing
|
||||||
|
def raw
|
||||||
|
render json: CharacterBlueprint.render(@character, view: :raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /characters/batch_preview
|
||||||
|
# Fetches wiki data and suggestions for multiple wiki page names
|
||||||
|
def batch_preview
|
||||||
|
wiki_pages = params[:wiki_pages]
|
||||||
|
wiki_data = params[:wiki_data] || {}
|
||||||
|
|
||||||
|
unless wiki_pages.is_a?(Array) && wiki_pages.any?
|
||||||
|
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Limit to 10 pages
|
||||||
|
wiki_pages = wiki_pages.first(10)
|
||||||
|
|
||||||
|
results = wiki_pages.map do |wiki_page|
|
||||||
|
process_wiki_preview(wiki_page, :character, wiki_raw: wiki_data[wiki_page])
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { results: results }
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /characters/:id/fetch_wiki
|
||||||
|
# Fetches and stores wiki data for this character
|
||||||
|
def fetch_wiki
|
||||||
|
unless @character.wiki_en.present?
|
||||||
|
return render json: { error: 'No wiki page configured for this character' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
wiki_text = Granblue::Parsers::Wiki.new.fetch(@character.wiki_en)
|
||||||
|
|
||||||
|
# Handle redirects
|
||||||
|
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
|
||||||
|
if redirect_match
|
||||||
|
redirect_target = redirect_match[1]
|
||||||
|
@character.update!(wiki_en: redirect_target)
|
||||||
|
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
|
||||||
|
end
|
||||||
|
|
||||||
|
@character.update!(wiki_raw: wiki_text)
|
||||||
|
render json: CharacterBlueprint.render(@character, view: :raw)
|
||||||
|
rescue Granblue::WikiError => e
|
||||||
|
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[CHARACTERS] Wiki fetch error for #{@character.id}: #{e.message}"
|
||||||
|
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set
|
def set
|
||||||
@character = Character.where(granblue_id: params[:id]).first
|
@character = find_by_any_id(Character, params[:id])
|
||||||
|
render_not_found_response('character') unless @character
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensures the current user has editor role (role >= 7)
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[CHARACTERS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def character_params
|
||||||
|
params.require(:character).permit(
|
||||||
|
:granblue_id, :name_en, :name_jp, :rarity, :element,
|
||||||
|
:proficiency1, :proficiency2, :gender, :race1, :race2,
|
||||||
|
:flb, :ulb, :special, :season,
|
||||||
|
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
|
||||||
|
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
|
||||||
|
:base_da, :base_ta, :ougi_ratio, :ougi_ratio_flb,
|
||||||
|
:release_date, :flb_date, :ulb_date,
|
||||||
|
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
|
||||||
|
nicknames_en: [], nicknames_jp: [], character_id: [], series: []
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
253
app/controllers/api/v1/collection_artifacts_controller.rb
Normal file
253
app/controllers/api/v1/collection_artifacts_controller.rb
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionArtifactsController < ApiController
|
||||||
|
# Read actions: look up user from params, check privacy
|
||||||
|
before_action :set_target_user, only: %i[index show]
|
||||||
|
before_action :check_collection_access, only: %i[index show]
|
||||||
|
before_action :set_collection_artifact_for_read, only: %i[show]
|
||||||
|
|
||||||
|
# Write actions: require auth, use current_user
|
||||||
|
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
|
||||||
|
before_action :set_collection_artifact_for_write, only: %i[update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@collection_artifacts = @target_user.collection_artifacts.includes(:artifact)
|
||||||
|
|
||||||
|
# Apply filters (array_param splits comma-separated values for OR logic)
|
||||||
|
@collection_artifacts = @collection_artifacts.where(artifact_id: params[:artifact_id]) if params[:artifact_id]
|
||||||
|
@collection_artifacts = @collection_artifacts.where(element: array_param(:element)) if params[:element]
|
||||||
|
@collection_artifacts = @collection_artifacts.by_proficiency(array_param(:proficiency)) if params[:proficiency].present?
|
||||||
|
@collection_artifacts = @collection_artifacts.joins(:artifact).where(artifacts: { rarity: array_param(:rarity) }) if params[:rarity]
|
||||||
|
|
||||||
|
# Skill filters - each slot uses OR logic, slots combined with AND logic
|
||||||
|
@collection_artifacts = @collection_artifacts.with_skill_in_slot(1, params[:skill1]) if params[:skill1].present?
|
||||||
|
@collection_artifacts = @collection_artifacts.with_skill_in_slot(2, params[:skill2]) if params[:skill2].present?
|
||||||
|
@collection_artifacts = @collection_artifacts.with_skill_in_slot(3, params[:skill3]) if params[:skill3].present?
|
||||||
|
@collection_artifacts = @collection_artifacts.with_skill_in_slot(4, params[:skill4]) if params[:skill4].present?
|
||||||
|
|
||||||
|
@collection_artifacts = @collection_artifacts.paginate(page: params[:page], per_page: params[:limit] || 50)
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionArtifactBlueprint.render(
|
||||||
|
@collection_artifacts,
|
||||||
|
root: :artifacts,
|
||||||
|
meta: pagination_meta(@collection_artifacts)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: Api::V1::CollectionArtifactBlueprint.render(
|
||||||
|
@collection_artifact,
|
||||||
|
view: :full
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@collection_artifact = current_user.collection_artifacts.build(collection_artifact_params)
|
||||||
|
|
||||||
|
if @collection_artifact.save
|
||||||
|
render json: Api::V1::CollectionArtifactBlueprint.render(
|
||||||
|
@collection_artifact,
|
||||||
|
view: :full
|
||||||
|
), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(@collection_artifact)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @collection_artifact.update(collection_artifact_params)
|
||||||
|
render json: Api::V1::CollectionArtifactBlueprint.render(
|
||||||
|
@collection_artifact,
|
||||||
|
view: :full
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@collection_artifact)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@collection_artifact.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/artifacts/batch
|
||||||
|
# Creates multiple collection artifacts in a single request
|
||||||
|
def batch
|
||||||
|
items = batch_artifact_params[:collection_artifacts] || []
|
||||||
|
created = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
items.each_with_index do |item_params, index|
|
||||||
|
collection_artifact = current_user.collection_artifacts.build(item_params)
|
||||||
|
|
||||||
|
if collection_artifact.save
|
||||||
|
created << collection_artifact
|
||||||
|
else
|
||||||
|
errors << {
|
||||||
|
index: index,
|
||||||
|
artifact_id: item_params[:artifact_id],
|
||||||
|
error: collection_artifact.errors.full_messages.join(', ')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
status = errors.any? ? :multi_status : :created
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionArtifactBlueprint.render(
|
||||||
|
created,
|
||||||
|
root: :artifacts,
|
||||||
|
meta: { created: created.size, errors: errors }
|
||||||
|
), status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/artifacts/import
|
||||||
|
# Imports artifacts from game JSON data
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing artifact list
|
||||||
|
# @param update_existing [Boolean] Whether to update existing artifacts (default: false)
|
||||||
|
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
|
||||||
|
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
|
||||||
|
def import
|
||||||
|
game_data = import_params[:data]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = ArtifactImportService.new(
|
||||||
|
current_user,
|
||||||
|
game_data,
|
||||||
|
update_existing: import_params[:update_existing] == true,
|
||||||
|
is_full_inventory: import_params[:is_full_inventory] == true,
|
||||||
|
reconcile_deletions: import_params[:reconcile_deletions] == true,
|
||||||
|
filter: import_params[:filter]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import
|
||||||
|
|
||||||
|
status = result.success? ? :created : :multi_status
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: result.success?,
|
||||||
|
created: result.created&.size || 0,
|
||||||
|
updated: result.updated&.size || 0,
|
||||||
|
skipped: result.skipped&.size || 0,
|
||||||
|
errors: result.errors || [],
|
||||||
|
reconciliation: result.reconciliation
|
||||||
|
}, status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/artifacts/preview_sync
|
||||||
|
# Previews what would be deleted in a full sync operation
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing artifact list
|
||||||
|
# @return [JSON] List of items that would be deleted
|
||||||
|
def preview_sync
|
||||||
|
game_data = import_params[:data]
|
||||||
|
filter = import_params[:filter]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = ArtifactImportService.new(current_user, game_data, filter: filter)
|
||||||
|
items_to_delete = service.preview_deletions
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
will_delete: items_to_delete.map do |ca|
|
||||||
|
{
|
||||||
|
id: ca.id,
|
||||||
|
game_id: ca.game_id,
|
||||||
|
name: ca.artifact&.name_en,
|
||||||
|
granblue_id: ca.artifact&.granblue_id,
|
||||||
|
element: ca.element,
|
||||||
|
level: ca.level
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
count: items_to_delete.size
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /collection/artifacts/batch_destroy
|
||||||
|
# Deletes multiple collection artifacts in a single request
|
||||||
|
def batch_destroy
|
||||||
|
ids = batch_destroy_params[:ids] || []
|
||||||
|
deleted_count = current_user.collection_artifacts.where(id: ids).destroy_all.count
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
meta: { deleted: deleted_count }
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_target_user
|
||||||
|
@target_user = User.find(params[:user_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: 'User not found' }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_collection_access
|
||||||
|
return if @target_user.nil?
|
||||||
|
|
||||||
|
return if @target_user.collection_viewable_by?(current_user)
|
||||||
|
|
||||||
|
render json: { error: 'You do not have permission to view this collection' }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_artifact_for_read
|
||||||
|
@collection_artifact = @target_user.collection_artifacts.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise CollectionErrors::CollectionItemNotFound.new('artifact', params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_artifact_for_write
|
||||||
|
@collection_artifact = current_user.collection_artifacts.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise CollectionErrors::CollectionItemNotFound.new('artifact', params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def collection_artifact_params
|
||||||
|
params.require(:collection_artifact).permit(
|
||||||
|
:artifact_id, :element, :proficiency, :level, :nickname, :reroll_slot,
|
||||||
|
skill1: %i[modifier strength level],
|
||||||
|
skill2: %i[modifier strength level],
|
||||||
|
skill3: %i[modifier strength level],
|
||||||
|
skill4: %i[modifier strength level]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_artifact_params
|
||||||
|
params.permit(collection_artifacts: [
|
||||||
|
:artifact_id, :element, :proficiency, :level, :nickname, :reroll_slot,
|
||||||
|
{ skill1: %i[modifier strength level] },
|
||||||
|
{ skill2: %i[modifier strength level] },
|
||||||
|
{ skill3: %i[modifier strength level] },
|
||||||
|
{ skill4: %i[modifier strength level] }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_params
|
||||||
|
{
|
||||||
|
update_existing: params[:update_existing],
|
||||||
|
is_full_inventory: params[:is_full_inventory],
|
||||||
|
reconcile_deletions: params[:reconcile_deletions],
|
||||||
|
data: params[:data]&.to_unsafe_h,
|
||||||
|
filter: params[:filter]&.to_unsafe_h
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_destroy_params
|
||||||
|
params.permit(ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def array_param(key)
|
||||||
|
params[key]&.to_s&.split(',')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
225
app/controllers/api/v1/collection_characters_controller.rb
Normal file
225
app/controllers/api/v1/collection_characters_controller.rb
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionCharactersController < ApiController
|
||||||
|
# Read actions: look up user from params, check privacy
|
||||||
|
before_action :set_target_user, only: %i[index show]
|
||||||
|
before_action :check_collection_access, only: %i[index show]
|
||||||
|
before_action :set_collection_character_for_read, only: %i[show]
|
||||||
|
|
||||||
|
# Write actions: require auth, use current_user
|
||||||
|
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import]
|
||||||
|
before_action :set_collection_character_for_write, only: %i[update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@collection_characters = @target_user.collection_characters
|
||||||
|
.includes(:character, :awakening)
|
||||||
|
|
||||||
|
# Apply filters (array_param splits comma-separated values for OR logic)
|
||||||
|
@collection_characters = @collection_characters.by_element(array_param(:element)) if params[:element]
|
||||||
|
@collection_characters = @collection_characters.by_rarity(array_param(:rarity)) if params[:rarity]
|
||||||
|
@collection_characters = @collection_characters.by_race(array_param(:race)) if params[:race]
|
||||||
|
@collection_characters = @collection_characters.by_proficiency(array_param(:proficiency)) if params[:proficiency]
|
||||||
|
@collection_characters = @collection_characters.by_gender(array_param(:gender)) if params[:gender]
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
@collection_characters = @collection_characters.sorted_by(params[:sort])
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# POST /collection/characters/batch
|
||||||
|
# Creates multiple collection characters in a single request
|
||||||
|
def batch
|
||||||
|
items = batch_character_params[:collection_characters] || []
|
||||||
|
created = []
|
||||||
|
skipped = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
items.each_with_index do |item_params, index|
|
||||||
|
# Check if already exists (skip duplicates)
|
||||||
|
if current_user.collection_characters.exists?(character_id: item_params[:character_id])
|
||||||
|
skipped << { index: index, character_id: item_params[:character_id], reason: 'already_exists' }
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
collection_character = current_user.collection_characters.build(item_params)
|
||||||
|
|
||||||
|
if collection_character.save
|
||||||
|
created << collection_character
|
||||||
|
else
|
||||||
|
errors << {
|
||||||
|
index: index,
|
||||||
|
character_id: item_params[:character_id],
|
||||||
|
error: collection_character.errors.full_messages.join(', ')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
status = errors.any? ? :multi_status : :created
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionCharacterBlueprint.render(
|
||||||
|
created,
|
||||||
|
root: :characters,
|
||||||
|
meta: { created: created.size, skipped: skipped.size, skipped_items: skipped, errors: errors }
|
||||||
|
), status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /collection/characters/batch_destroy
|
||||||
|
# Deletes multiple collection characters in a single request
|
||||||
|
def batch_destroy
|
||||||
|
ids = batch_destroy_params[:ids] || []
|
||||||
|
deleted_count = current_user.collection_characters.where(id: ids).destroy_all.count
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
meta: { deleted: deleted_count }
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/characters/import
|
||||||
|
# Imports characters from game JSON data
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing character list
|
||||||
|
# @param update_existing [Boolean] Whether to update existing characters (default: false)
|
||||||
|
def import
|
||||||
|
game_data = import_params[:data]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = CharacterImportService.new(
|
||||||
|
current_user,
|
||||||
|
game_data,
|
||||||
|
update_existing: import_params[:update_existing] == true
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import
|
||||||
|
|
||||||
|
status = result.success? ? :created : :multi_status
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: result.success?,
|
||||||
|
created: result.created.size,
|
||||||
|
updated: result.updated.size,
|
||||||
|
skipped: result.skipped.size,
|
||||||
|
errors: result.errors
|
||||||
|
}, status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_target_user
|
||||||
|
@target_user = User.find(params[:user_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "User not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_collection_access
|
||||||
|
return if @target_user.nil? # Already handled by set_target_user
|
||||||
|
unless @target_user.collection_viewable_by?(current_user)
|
||||||
|
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_character_for_read
|
||||||
|
@collection_character = @target_user.collection_characters.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise CollectionErrors::CollectionItemNotFound.new('character', params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_character_for_write
|
||||||
|
@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
|
||||||
|
|
||||||
|
def batch_character_params
|
||||||
|
params.permit(collection_characters: [
|
||||||
|
: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
|
||||||
|
|
||||||
|
def import_params
|
||||||
|
{
|
||||||
|
update_existing: params[:update_existing],
|
||||||
|
data: params[:data]&.to_unsafe_h
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_destroy_params
|
||||||
|
params.permit(ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def array_param(key)
|
||||||
|
params[key]&.to_s&.split(',')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
34
app/controllers/api/v1/collection_controller.rb
Normal file
34
app/controllers/api/v1/collection_controller.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionController < ApiController
|
||||||
|
before_action :set_target_user
|
||||||
|
before_action :check_collection_access
|
||||||
|
|
||||||
|
# GET /api/v1/users/:user_id/collection/counts
|
||||||
|
# Returns total counts for all collection entity types
|
||||||
|
def counts
|
||||||
|
render json: {
|
||||||
|
characters: @target_user.collection_characters.count,
|
||||||
|
weapons: @target_user.collection_weapons.count,
|
||||||
|
summons: @target_user.collection_summons.count,
|
||||||
|
artifacts: @target_user.collection_artifacts.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_target_user
|
||||||
|
@target_user = User.find(params[:user_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "User not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_collection_access
|
||||||
|
return if @target_user.nil?
|
||||||
|
unless @target_user.collection_viewable_by?(current_user)
|
||||||
|
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionJobAccessoriesController < ApiController
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_collection_job_accessory, only: [:show, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@collection_accessories = current_user.collection_job_accessories
|
||||||
|
.includes(job_accessory: :job)
|
||||||
|
|
||||||
|
@collection_accessories = @collection_accessories.by_job(params[:job_id]) if params[:job_id]
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
|
||||||
|
@collection_accessories,
|
||||||
|
root: :collection_job_accessories
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
|
||||||
|
@collection_job_accessory
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@collection_accessory = current_user.collection_job_accessories
|
||||||
|
.build(collection_job_accessory_params)
|
||||||
|
|
||||||
|
if @collection_accessory.save
|
||||||
|
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
|
||||||
|
@collection_accessory
|
||||||
|
), status: :created
|
||||||
|
else
|
||||||
|
# Check for duplicate job accessory error
|
||||||
|
if @collection_accessory.errors[:job_accessory_id].any? { |e| e.include?('already exists') }
|
||||||
|
raise CollectionErrors::DuplicateJobAccessory.new(@collection_accessory.job_accessory_id)
|
||||||
|
end
|
||||||
|
render_validation_error_response(@collection_accessory)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@collection_job_accessory.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_collection_job_accessory
|
||||||
|
@collection_job_accessory = current_user.collection_job_accessories.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise CollectionErrors::CollectionItemNotFound.new('job accessory', params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def collection_job_accessory_params
|
||||||
|
params.require(:collection_job_accessory).permit(:job_accessory_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
237
app/controllers/api/v1/collection_summons_controller.rb
Normal file
237
app/controllers/api/v1/collection_summons_controller.rb
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionSummonsController < ApiController
|
||||||
|
# Read actions: look up user from params, check privacy
|
||||||
|
before_action :set_target_user, only: %i[index show]
|
||||||
|
before_action :check_collection_access, only: %i[index show]
|
||||||
|
before_action :set_collection_summon_for_read, only: %i[show]
|
||||||
|
|
||||||
|
# Write actions: require auth, use current_user
|
||||||
|
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
|
||||||
|
before_action :set_collection_summon_for_write, only: %i[update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@collection_summons = @target_user.collection_summons
|
||||||
|
.includes(:summon)
|
||||||
|
|
||||||
|
# Apply filters (array_param splits comma-separated values for OR logic)
|
||||||
|
@collection_summons = @collection_summons.by_summon(params[:summon_id]) if params[:summon_id]
|
||||||
|
@collection_summons = @collection_summons.by_element(array_param(:element)) if params[:element]
|
||||||
|
@collection_summons = @collection_summons.by_rarity(array_param(: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: :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
|
||||||
|
|
||||||
|
# POST /collection/summons/batch
|
||||||
|
# Creates multiple collection summons in a single request
|
||||||
|
# Unlike characters, summons can have duplicates (user can own multiple copies)
|
||||||
|
def batch
|
||||||
|
items = batch_summon_params[:collection_summons] || []
|
||||||
|
created = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
items.each_with_index do |item_params, index|
|
||||||
|
collection_summon = current_user.collection_summons.build(item_params)
|
||||||
|
|
||||||
|
if collection_summon.save
|
||||||
|
created << collection_summon
|
||||||
|
else
|
||||||
|
errors << {
|
||||||
|
index: index,
|
||||||
|
summon_id: item_params[:summon_id],
|
||||||
|
error: collection_summon.errors.full_messages.join(', ')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
status = errors.any? ? :multi_status : :created
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionSummonBlueprint.render(
|
||||||
|
created,
|
||||||
|
root: :summons,
|
||||||
|
meta: { created: created.size, errors: errors }
|
||||||
|
), status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /collection/summons/batch_destroy
|
||||||
|
# Deletes multiple collection summons in a single request
|
||||||
|
def batch_destroy
|
||||||
|
ids = batch_destroy_params[:ids] || []
|
||||||
|
deleted_count = current_user.collection_summons.where(id: ids).destroy_all.count
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
meta: { deleted: deleted_count }
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/summons/import
|
||||||
|
# Imports summons from game JSON data
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing summon list
|
||||||
|
# @param update_existing [Boolean] Whether to update existing summons (default: false)
|
||||||
|
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
|
||||||
|
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
|
||||||
|
def import
|
||||||
|
game_data = import_params[:data]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = SummonImportService.new(
|
||||||
|
current_user,
|
||||||
|
game_data,
|
||||||
|
update_existing: import_params[:update_existing] == true,
|
||||||
|
is_full_inventory: import_params[:is_full_inventory] == true,
|
||||||
|
reconcile_deletions: import_params[:reconcile_deletions] == true,
|
||||||
|
filter: import_params[:filter]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import
|
||||||
|
|
||||||
|
status = result.success? ? :created : :multi_status
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: result.success?,
|
||||||
|
created: result.created.size,
|
||||||
|
updated: result.updated.size,
|
||||||
|
skipped: result.skipped.size,
|
||||||
|
errors: result.errors,
|
||||||
|
reconciliation: result.reconciliation
|
||||||
|
}, status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/summons/preview_sync
|
||||||
|
# Previews what would be deleted in a full sync operation
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing summon list
|
||||||
|
# @return [JSON] List of items that would be deleted
|
||||||
|
def preview_sync
|
||||||
|
game_data = import_params[:data]
|
||||||
|
filter = import_params[:filter]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = SummonImportService.new(current_user, game_data, filter: filter)
|
||||||
|
items_to_delete = service.preview_deletions
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
will_delete: items_to_delete.map do |cs|
|
||||||
|
{
|
||||||
|
id: cs.id,
|
||||||
|
game_id: cs.game_id,
|
||||||
|
name: cs.summon&.name_en,
|
||||||
|
granblue_id: cs.summon&.granblue_id,
|
||||||
|
uncap_level: cs.uncap_level,
|
||||||
|
transcendence_step: cs.transcendence_step
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
count: items_to_delete.size
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_target_user
|
||||||
|
@target_user = User.find(params[:user_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "User not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_collection_access
|
||||||
|
return if @target_user.nil? # Already handled by set_target_user
|
||||||
|
unless @target_user.collection_viewable_by?(current_user)
|
||||||
|
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_summon_for_read
|
||||||
|
@collection_summon = @target_user.collection_summons.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise CollectionErrors::CollectionItemNotFound.new('summon', params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_summon_for_write
|
||||||
|
@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
|
||||||
|
|
||||||
|
def batch_summon_params
|
||||||
|
params.permit(collection_summons: [
|
||||||
|
:summon_id, :uncap_level, :transcendence_step
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_params
|
||||||
|
{
|
||||||
|
update_existing: params[:update_existing],
|
||||||
|
is_full_inventory: params[:is_full_inventory],
|
||||||
|
reconcile_deletions: params[:reconcile_deletions],
|
||||||
|
data: params[:data]&.to_unsafe_h,
|
||||||
|
filter: params[:filter]&.to_unsafe_h
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_destroy_params
|
||||||
|
params.permit(ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def array_param(key)
|
||||||
|
params[key]&.to_s&.split(',')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
255
app/controllers/api/v1/collection_weapons_controller.rb
Normal file
255
app/controllers/api/v1/collection_weapons_controller.rb
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CollectionWeaponsController < ApiController
|
||||||
|
# Read actions: look up user from params, check privacy
|
||||||
|
before_action :set_target_user, only: %i[index show]
|
||||||
|
before_action :check_collection_access, only: %i[index show]
|
||||||
|
before_action :set_collection_weapon_for_read, only: %i[show]
|
||||||
|
|
||||||
|
# Write actions: require auth, use current_user
|
||||||
|
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
|
||||||
|
before_action :set_collection_weapon_for_write, only: %i[update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@collection_weapons = @target_user.collection_weapons
|
||||||
|
.includes(:weapon, :awakening,
|
||||||
|
:weapon_key1, :weapon_key2,
|
||||||
|
:weapon_key3, :weapon_key4,
|
||||||
|
:ax_modifier1, :ax_modifier2,
|
||||||
|
:befoulment_modifier)
|
||||||
|
|
||||||
|
# Apply filters (array_param splits comma-separated values for OR logic)
|
||||||
|
@collection_weapons = @collection_weapons.by_weapon(params[:weapon_id]) if params[:weapon_id]
|
||||||
|
@collection_weapons = @collection_weapons.by_element(array_param(:element)) if params[:element]
|
||||||
|
@collection_weapons = @collection_weapons.by_rarity(array_param(:rarity)) if params[:rarity]
|
||||||
|
@collection_weapons = @collection_weapons.by_proficiency(array_param(:proficiency)) if params[:proficiency]
|
||||||
|
@collection_weapons = @collection_weapons.by_series(array_param(:series)) if params[:series]
|
||||||
|
|
||||||
|
@collection_weapons = @collection_weapons.sorted_by(params[:sort])
|
||||||
|
|
||||||
|
@collection_weapons = @collection_weapons.paginate(page: params[:page], per_page: params[:limit] || 50)
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionWeaponBlueprint.render(
|
||||||
|
@collection_weapons,
|
||||||
|
root: :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
|
||||||
|
|
||||||
|
# POST /collection/weapons/batch
|
||||||
|
# Creates multiple collection weapons in a single request
|
||||||
|
# Unlike characters, weapons can have duplicates (user can own multiple copies)
|
||||||
|
def batch
|
||||||
|
items = batch_weapon_params[:collection_weapons] || []
|
||||||
|
created = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
items.each_with_index do |item_params, index|
|
||||||
|
collection_weapon = current_user.collection_weapons.build(item_params)
|
||||||
|
|
||||||
|
if collection_weapon.save
|
||||||
|
created << collection_weapon
|
||||||
|
else
|
||||||
|
errors << {
|
||||||
|
index: index,
|
||||||
|
weapon_id: item_params[:weapon_id],
|
||||||
|
error: collection_weapon.errors.full_messages.join(', ')
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
status = errors.any? ? :multi_status : :created
|
||||||
|
|
||||||
|
render json: Api::V1::CollectionWeaponBlueprint.render(
|
||||||
|
created,
|
||||||
|
root: :weapons,
|
||||||
|
meta: { created: created.size, errors: errors }
|
||||||
|
), status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /collection/weapons/batch_destroy
|
||||||
|
# Deletes multiple collection weapons in a single request
|
||||||
|
def batch_destroy
|
||||||
|
ids = batch_destroy_params[:ids] || []
|
||||||
|
deleted_count = current_user.collection_weapons.where(id: ids).destroy_all.count
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
meta: { deleted: deleted_count }
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/weapons/import
|
||||||
|
# Imports weapons from game JSON data
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing weapon list
|
||||||
|
# @param update_existing [Boolean] Whether to update existing weapons (default: false)
|
||||||
|
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
|
||||||
|
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
|
||||||
|
def import
|
||||||
|
game_data = import_params[:data]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = WeaponImportService.new(
|
||||||
|
current_user,
|
||||||
|
game_data,
|
||||||
|
update_existing: import_params[:update_existing] == true,
|
||||||
|
is_full_inventory: import_params[:is_full_inventory] == true,
|
||||||
|
reconcile_deletions: import_params[:reconcile_deletions] == true,
|
||||||
|
filter: import_params[:filter]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.import
|
||||||
|
|
||||||
|
status = result.success? ? :created : :multi_status
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: result.success?,
|
||||||
|
created: result.created.size,
|
||||||
|
updated: result.updated.size,
|
||||||
|
skipped: result.skipped.size,
|
||||||
|
errors: result.errors,
|
||||||
|
reconciliation: result.reconciliation
|
||||||
|
}, status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /collection/weapons/preview_sync
|
||||||
|
# Previews what would be deleted in a full sync operation
|
||||||
|
#
|
||||||
|
# @param data [Hash] Game data containing weapon list
|
||||||
|
# @return [JSON] List of items that would be deleted
|
||||||
|
def preview_sync
|
||||||
|
game_data = import_params[:data]
|
||||||
|
filter = import_params[:filter]
|
||||||
|
|
||||||
|
unless game_data.present?
|
||||||
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
service = WeaponImportService.new(current_user, game_data, filter: filter)
|
||||||
|
items_to_delete = service.preview_deletions
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
will_delete: items_to_delete.map do |cw|
|
||||||
|
{
|
||||||
|
id: cw.id,
|
||||||
|
game_id: cw.game_id,
|
||||||
|
name: cw.weapon&.name_en,
|
||||||
|
granblue_id: cw.weapon&.granblue_id,
|
||||||
|
uncap_level: cw.uncap_level,
|
||||||
|
transcendence_step: cw.transcendence_step
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
count: items_to_delete.size
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_target_user
|
||||||
|
@target_user = User.find(params[:user_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "User not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_collection_access
|
||||||
|
return if @target_user.nil? # Already handled by set_target_user
|
||||||
|
unless @target_user.collection_viewable_by?(current_user)
|
||||||
|
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_weapon_for_read
|
||||||
|
@collection_weapon = @target_user.collection_weapons.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise CollectionErrors::CollectionItemNotFound.new('weapon', params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_collection_weapon_for_write
|
||||||
|
@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_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
|
||||||
|
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
|
||||||
|
:element
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_weapon_params
|
||||||
|
params.permit(collection_weapons: [
|
||||||
|
:weapon_id, :uncap_level, :transcendence_step,
|
||||||
|
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
|
||||||
|
:awakening_id, :awakening_level,
|
||||||
|
:ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
|
||||||
|
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
|
||||||
|
:element
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_params
|
||||||
|
{
|
||||||
|
update_existing: params[:update_existing],
|
||||||
|
is_full_inventory: params[:is_full_inventory],
|
||||||
|
reconcile_deletions: params[:reconcile_deletions],
|
||||||
|
data: params[:data]&.to_unsafe_h,
|
||||||
|
filter: params[:filter]&.to_unsafe_h
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_destroy_params
|
||||||
|
params.permit(ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def array_param(key)
|
||||||
|
params[key]&.to_s&.split(',')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
96
app/controllers/api/v1/crew_gw_participations_controller.rb
Normal file
96
app/controllers/api/v1/crew_gw_participations_controller.rb
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewGwParticipationsController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew
|
||||||
|
before_action :authorize_crew_member!
|
||||||
|
before_action :set_participation, only: %i[show update]
|
||||||
|
before_action :authorize_crew_officer!, only: %i[create update]
|
||||||
|
|
||||||
|
# GET /crew/gw_participations
|
||||||
|
def index
|
||||||
|
participations = @crew.crew_gw_participations.includes(:gw_event).order('gw_events.start_date DESC')
|
||||||
|
render json: CrewGwParticipationBlueprint.render(participations, view: :with_event, root: :crew_gw_participations)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crew/gw_participations/:id
|
||||||
|
def show
|
||||||
|
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_individual_scores, root: :crew_gw_participation, current_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crew/gw_participations/by_event/:event_id
|
||||||
|
def by_event
|
||||||
|
# Support lookup by event_id (UUID) or event_number (integer)
|
||||||
|
event = if params[:event_id].match?(/\A\d+\z/)
|
||||||
|
GwEvent.find_by(event_number: params[:event_id])
|
||||||
|
else
|
||||||
|
GwEvent.find_by(id: params[:event_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
return render json: { gw_event: nil, crew_gw_participation: nil, members_during_event: [] } unless event
|
||||||
|
|
||||||
|
participation = @crew.crew_gw_participations
|
||||||
|
.includes(:gw_event, gw_individual_scores: [{ crew_membership: :user }, :phantom_player])
|
||||||
|
.find_by(gw_event: event)
|
||||||
|
|
||||||
|
# Get all members who were active during the event (includes retired members who left after event started)
|
||||||
|
# Also include all currently active members for score entry purposes
|
||||||
|
# Uses joined_at (editable) for historical accuracy
|
||||||
|
members_during_event = @crew.crew_memberships
|
||||||
|
.includes(:user)
|
||||||
|
.active_during(event.start_date, event.end_date)
|
||||||
|
|
||||||
|
# Get all phantom players who were active during the event (excludes claimed/deleted phantoms)
|
||||||
|
phantom_players = @crew.phantom_players.not_deleted.active_during(event.start_date, event.end_date)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
gw_event: GwEventBlueprint.render_as_hash(event),
|
||||||
|
crew_gw_participation: participation ? CrewGwParticipationBlueprint.render_as_hash(participation, view: :with_individual_scores, current_user: current_user) : nil,
|
||||||
|
members_during_event: CrewMembershipBlueprint.render_as_hash(members_during_event, view: :with_user),
|
||||||
|
phantom_players: PhantomPlayerBlueprint.render_as_hash(phantom_players)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /gw_events/:id/participations
|
||||||
|
def create
|
||||||
|
event = GwEvent.find(params[:id])
|
||||||
|
|
||||||
|
participation = @crew.crew_gw_participations.build(gw_event: event)
|
||||||
|
|
||||||
|
if participation.save
|
||||||
|
render json: CrewGwParticipationBlueprint.render(participation, view: :with_event, root: :crew_gw_participation), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(participation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /crew/gw_participations/:id
|
||||||
|
def update
|
||||||
|
if @participation.update(participation_params)
|
||||||
|
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_event, root: :crew_gw_participation)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@participation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = current_user.crew
|
||||||
|
raise CrewErrors::NotInCrewError unless @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_participation
|
||||||
|
@participation = @crew.crew_gw_participations.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def participation_params
|
||||||
|
params.require(:crew_gw_participation).permit(:preliminary_ranking, :final_ranking)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
78
app/controllers/api/v1/crew_invitations_controller.rb
Normal file
78
app/controllers/api/v1/crew_invitations_controller.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewInvitationsController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew, only: %i[index create]
|
||||||
|
before_action :authorize_crew_officer!, only: %i[index create]
|
||||||
|
before_action :set_invitation, only: %i[accept reject]
|
||||||
|
|
||||||
|
# GET /crews/:crew_id/invitations
|
||||||
|
# List pending invitations for a crew (officers only)
|
||||||
|
def index
|
||||||
|
invitations = @crew.crew_invitations.pending.includes(:user, :invited_by)
|
||||||
|
render json: CrewInvitationBlueprint.render(invitations, view: :with_user, root: :invitations)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/invitations
|
||||||
|
# Send an invitation to a user (officers only)
|
||||||
|
def create
|
||||||
|
user = User.find_by(id: params[:user_id]) || User.find_by(username: params[:username])
|
||||||
|
raise ActiveRecord::RecordNotFound, 'User not found' unless user
|
||||||
|
raise CrewErrors::CannotInviteSelfError if user.id == current_user.id
|
||||||
|
raise CrewErrors::AlreadyInCrewError if user.crew.present?
|
||||||
|
|
||||||
|
# Check for existing pending invitation
|
||||||
|
existing = @crew.crew_invitations.pending.find_by(user: user)
|
||||||
|
raise CrewErrors::UserAlreadyInvitedError if existing
|
||||||
|
|
||||||
|
invitation = @crew.crew_invitations.build(
|
||||||
|
user: user,
|
||||||
|
invited_by: current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
if invitation.save
|
||||||
|
render json: CrewInvitationBlueprint.render(invitation, view: :with_user, root: :invitation), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(invitation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /invitations/pending
|
||||||
|
# List pending invitations for current user
|
||||||
|
def pending
|
||||||
|
invitations = current_user.crew_invitations.active.includes(:crew, :invited_by)
|
||||||
|
render json: CrewInvitationBlueprint.render(invitations, view: :for_invitee, root: :invitations)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /invitations/:id/accept
|
||||||
|
def accept
|
||||||
|
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
|
||||||
|
|
||||||
|
@invitation.accept!
|
||||||
|
render json: CrewBlueprint.render(current_user.crew, view: :full, root: :crew)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /invitations/:id/reject
|
||||||
|
def reject
|
||||||
|
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
|
||||||
|
|
||||||
|
@invitation.reject!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = Crew.find(params[:crew_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_invitation
|
||||||
|
@invitation = CrewInvitation.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
149
app/controllers/api/v1/crew_memberships_controller.rb
Normal file
149
app/controllers/api/v1/crew_memberships_controller.rb
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewMembershipsController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew, except: %i[gw_scores]
|
||||||
|
before_action :set_crew_from_user, only: %i[gw_scores]
|
||||||
|
before_action :set_membership, only: %i[update destroy promote demote]
|
||||||
|
before_action :set_membership_for_scores, only: %i[gw_scores]
|
||||||
|
before_action :authorize_crew_officer!, only: %i[destroy history]
|
||||||
|
before_action :authorize_crew_captain!, only: %i[promote demote]
|
||||||
|
before_action :authorize_membership_update!, only: %i[update]
|
||||||
|
before_action :authorize_crew_member!, only: %i[gw_scores]
|
||||||
|
|
||||||
|
# PUT /crews/:crew_id/memberships/:id
|
||||||
|
def update
|
||||||
|
allowed_params = if current_user.crew_captain?
|
||||||
|
membership_params
|
||||||
|
else
|
||||||
|
membership_params.slice(:joined_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @membership.update(allowed_params)
|
||||||
|
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@membership)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /crews/:crew_id/memberships/:id
|
||||||
|
def destroy
|
||||||
|
raise CrewErrors::CannotRemoveCaptainError if @membership.captain?
|
||||||
|
|
||||||
|
@membership.retire!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/memberships/:id/promote
|
||||||
|
def promote
|
||||||
|
raise CrewErrors::CannotRemoveCaptainError if @membership.captain?
|
||||||
|
|
||||||
|
# Check vice captain limit
|
||||||
|
current_vc_count = @crew.crew_memberships.where(role: :vice_captain, retired: false).count
|
||||||
|
raise CrewErrors::ViceCaptainLimitError if current_vc_count >= 3 && !@membership.vice_captain?
|
||||||
|
|
||||||
|
@membership.update!(role: :vice_captain)
|
||||||
|
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/memberships/:id/demote
|
||||||
|
def demote
|
||||||
|
raise CrewErrors::CannotDemoteCaptainError if @membership.captain?
|
||||||
|
|
||||||
|
@membership.update!(role: :member)
|
||||||
|
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crews/:crew_id/memberships/by_user/:user_id
|
||||||
|
def history
|
||||||
|
memberships = @crew.crew_memberships
|
||||||
|
.where(user_id: params[:user_id])
|
||||||
|
.order(created_at: :desc)
|
||||||
|
render json: CrewMembershipBlueprint.render(memberships, view: :with_user, root: :memberships)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crew/memberships/:id/gw_scores
|
||||||
|
def gw_scores
|
||||||
|
# Find ALL memberships for this user in the crew (for boomerang players)
|
||||||
|
all_memberships = @crew.crew_memberships.where(user_id: @membership.user_id)
|
||||||
|
membership_ids = all_memberships.pluck(:id)
|
||||||
|
|
||||||
|
# Get all crew GW events to identify gaps
|
||||||
|
all_crew_events = @crew.crew_gw_participations
|
||||||
|
.joins(:gw_event)
|
||||||
|
.order('gw_events.event_number DESC')
|
||||||
|
.pluck('gw_events.id, gw_events.event_number, gw_events.element, gw_events.start_date, gw_events.end_date')
|
||||||
|
|
||||||
|
# Get scores across all membership periods
|
||||||
|
scores_by_event = GwIndividualScore
|
||||||
|
.joins(crew_gw_participation: :gw_event)
|
||||||
|
.where(crew_membership_id: membership_ids)
|
||||||
|
.group('gw_events.id')
|
||||||
|
.pluck('gw_events.id, SUM(gw_individual_scores.score)')
|
||||||
|
.to_h
|
||||||
|
|
||||||
|
# Build event scores with gap markers
|
||||||
|
event_scores = all_crew_events.map do |event_id, event_number, element, start_date, end_date|
|
||||||
|
score = scores_by_event[event_id]
|
||||||
|
{
|
||||||
|
gw_event: { id: event_id, event_number: event_number, element: element, start_date: start_date, end_date: end_date },
|
||||||
|
total_score: score&.to_i,
|
||||||
|
in_crew: score.present?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
grand_total = event_scores.sum { |es| es[:total_score] || 0 }
|
||||||
|
|
||||||
|
# Build membership periods for context
|
||||||
|
membership_periods = all_memberships.order(created_at: :desc).map do |m|
|
||||||
|
{ id: m.id, joined_at: m.joined_at, retired_at: m.retired_at, retired: m.retired }
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
member: CrewMembershipBlueprint.render_as_hash(@membership, view: :with_user),
|
||||||
|
event_scores: event_scores,
|
||||||
|
grand_total: grand_total,
|
||||||
|
membership_periods: membership_periods
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = Crew.find(params[:crew_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_crew_from_user
|
||||||
|
@crew = current_user.crew
|
||||||
|
raise CrewErrors::NotInCrewError unless @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_membership
|
||||||
|
@membership = @crew.crew_memberships.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_membership_for_scores
|
||||||
|
# Try to find by username first, then fall back to ID
|
||||||
|
@membership = @crew.crew_memberships.joins(:user).find_by(users: { username: params[:id] }) ||
|
||||||
|
@crew.crew_memberships.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def membership_params
|
||||||
|
params.require(:membership).permit(:role, :joined_at, :retired, :retired_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_membership_update!
|
||||||
|
# Officers can update any membership's joined_at
|
||||||
|
# Captains can update anything
|
||||||
|
return if current_user.crew_captain?
|
||||||
|
return if current_user.crew_officer?
|
||||||
|
|
||||||
|
raise Api::V1::UnauthorizedError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
185
app/controllers/api/v1/crews_controller.rb
Normal file
185
app/controllers/api/v1/crews_controller.rb
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CrewsController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew, only: %i[show update members roster leave transfer_captain]
|
||||||
|
before_action :require_crew!, only: %i[show update members roster]
|
||||||
|
before_action :authorize_crew_member!, only: %i[show members]
|
||||||
|
before_action :authorize_crew_officer!, only: %i[update roster]
|
||||||
|
before_action :authorize_crew_captain!, only: %i[transfer_captain]
|
||||||
|
|
||||||
|
# GET /crew or GET /crews/:id
|
||||||
|
def show
|
||||||
|
render json: CrewBlueprint.render(@crew, view: :full, root: :crew, current_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews
|
||||||
|
def create
|
||||||
|
raise CrewErrors::AlreadyInCrewError if current_user.crew.present?
|
||||||
|
|
||||||
|
@crew = Crew.new(crew_params)
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@crew.save!
|
||||||
|
CrewMembership.create!(crew: @crew, user: current_user, role: :captain)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user), status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /crew
|
||||||
|
def update
|
||||||
|
if @crew.update(crew_params)
|
||||||
|
render json: CrewBlueprint.render(@crew, view: :full, root: :crew, current_user: current_user)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@crew)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crew/members
|
||||||
|
# Params:
|
||||||
|
# filter: 'active' (default), 'retired', 'phantom', 'all'
|
||||||
|
def members
|
||||||
|
filter = params[:filter]&.to_sym || :active
|
||||||
|
|
||||||
|
case filter
|
||||||
|
when :active
|
||||||
|
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
|
||||||
|
phantoms = @crew.phantom_players.not_deleted.active.includes(:claimed_by).order(:name)
|
||||||
|
when :retired
|
||||||
|
members = @crew.crew_memberships.retired.includes(:user).order(retired_at: :desc)
|
||||||
|
phantoms = @crew.phantom_players.not_deleted.retired.includes(:claimed_by).order(:name)
|
||||||
|
when :phantom
|
||||||
|
members = []
|
||||||
|
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
|
||||||
|
when :all
|
||||||
|
members = @crew.crew_memberships.includes(:user).order(role: :desc, retired: :asc, created_at: :asc)
|
||||||
|
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
|
||||||
|
else
|
||||||
|
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
|
||||||
|
phantoms = @crew.phantom_players.not_deleted.active.includes(:claimed_by).order(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
members: CrewMembershipBlueprint.render_as_hash(members, view: :with_user),
|
||||||
|
phantoms: PhantomPlayerBlueprint.render_as_hash(phantoms, view: :with_claimed_by)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crew/leave
|
||||||
|
def leave
|
||||||
|
membership = current_user.active_crew_membership
|
||||||
|
raise CrewErrors::NotInCrewError unless membership
|
||||||
|
raise CrewErrors::CaptainCannotLeaveError if membership.captain?
|
||||||
|
|
||||||
|
membership.retire!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crew/roster
|
||||||
|
# Returns collection ownership for crew members based on requested item IDs
|
||||||
|
# Params: character_ids[], weapon_ids[], summon_ids[]
|
||||||
|
def roster
|
||||||
|
members = @crew.active_memberships.includes(:user)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
members: members.map { |m| build_member_roster(m) }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:id/transfer_captain
|
||||||
|
def transfer_captain
|
||||||
|
new_captain_id = params[:user_id]
|
||||||
|
new_captain_membership = @crew.active_memberships.find_by(user_id: new_captain_id)
|
||||||
|
|
||||||
|
raise CrewErrors::MemberNotFoundError unless new_captain_membership
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
current_user.active_crew_membership.update!(role: :vice_captain)
|
||||||
|
new_captain_membership.update!(role: :captain)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = if params[:id]
|
||||||
|
Crew.find(params[:id])
|
||||||
|
else
|
||||||
|
current_user&.crew
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def crew_params
|
||||||
|
params.require(:crew).permit(:name, :gamertag, :granblue_crew_id, :description)
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_crew!
|
||||||
|
render_not_found_response('crew') unless @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_member_roster(membership)
|
||||||
|
user = membership.user
|
||||||
|
{
|
||||||
|
user_id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: membership.role,
|
||||||
|
characters: find_collection_items(user, :characters),
|
||||||
|
weapons: find_collection_items(user, :weapons),
|
||||||
|
summons: find_collection_items(user, :summons)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_collection_items(user, type)
|
||||||
|
ids = params["#{type.to_s.singularize}_ids"]
|
||||||
|
return [] if ids.blank?
|
||||||
|
|
||||||
|
collection = case type
|
||||||
|
when :characters then user.collection_characters.includes(:character).where(character_id: ids)
|
||||||
|
when :weapons then user.collection_weapons.includes(:weapon).where(weapon_id: ids)
|
||||||
|
when :summons then user.collection_summons.includes(:summon).where(summon_id: ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
collection.map do |item|
|
||||||
|
canonical = case type
|
||||||
|
when :characters then item.character
|
||||||
|
when :weapons then item.weapon
|
||||||
|
when :summons then item.summon
|
||||||
|
end
|
||||||
|
|
||||||
|
result = {
|
||||||
|
id: item_id_for(item, type),
|
||||||
|
uncap_level: item.uncap_level,
|
||||||
|
transcendence_step: item.transcendence_step,
|
||||||
|
flb: canonical&.flb,
|
||||||
|
ulb: canonical&.ulb
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == :characters
|
||||||
|
result[:special] = canonical&.special
|
||||||
|
# For characters, transcendence availability is indicated by ulb on non-special chars
|
||||||
|
result[:transcendence] = !canonical&.special && canonical&.ulb
|
||||||
|
else
|
||||||
|
result[:transcendence] = canonical&.transcendence
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def item_id_for(item, type)
|
||||||
|
case type
|
||||||
|
when :characters then item.character_id
|
||||||
|
when :weapons then item.weapon_id
|
||||||
|
when :summons then item.summon_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -31,10 +31,15 @@ module Api
|
||||||
raise Api::V1::UnauthorizedError unless current_user
|
raise Api::V1::UnauthorizedError unless current_user
|
||||||
|
|
||||||
@favorite = Favorite.where(user_id: current_user.id, party_id: favorite_params[:party_id]).first
|
@favorite = Favorite.where(user_id: current_user.id, party_id: favorite_params[:party_id]).first
|
||||||
render_not_found_response('favorite') unless @favorite
|
return render_not_found_response('favorite') unless @favorite
|
||||||
|
|
||||||
render_error("Couldn't delete favorite") unless Favorite.destroy(@favorite.id)
|
if Favorite.destroy(@favorite.id)
|
||||||
render json: FavoriteBlueprint.render(@favorite, root: :favorite, view: :destroyed)
|
render json: FavoriteBlueprint.render(@favorite, root: :favorite, view: :destroyed)
|
||||||
|
else
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new("Couldn't delete favorite")
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
134
app/controllers/api/v1/grid_artifacts_controller.rb
Normal file
134
app/controllers/api/v1/grid_artifacts_controller.rb
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GridArtifactsController < Api::V1::ApiController
|
||||||
|
before_action :find_grid_artifact, only: %i[update destroy sync]
|
||||||
|
before_action :find_party, only: %i[create update destroy sync]
|
||||||
|
before_action :find_grid_character, only: %i[create]
|
||||||
|
before_action :find_artifact, only: %i[create]
|
||||||
|
before_action :authorize_party_edit!, only: %i[create update destroy sync]
|
||||||
|
|
||||||
|
# POST /grid_artifacts
|
||||||
|
def create
|
||||||
|
# Check if grid_character already has an artifact
|
||||||
|
if @grid_character.grid_artifact.present?
|
||||||
|
@grid_character.grid_artifact.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
@grid_artifact = GridArtifact.new(
|
||||||
|
grid_artifact_params.merge(
|
||||||
|
grid_character_id: @grid_character.id,
|
||||||
|
artifact_id: @artifact.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if @grid_artifact.save
|
||||||
|
render json: GridArtifactBlueprint.render(@grid_artifact, view: :nested, root: :grid_artifact), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(@grid_artifact)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /grid_artifacts/:id
|
||||||
|
def update
|
||||||
|
if @grid_artifact.update(grid_artifact_params)
|
||||||
|
render json: GridArtifactBlueprint.render(@grid_artifact, view: :nested, root: :grid_artifact), status: :ok
|
||||||
|
else
|
||||||
|
render_validation_error_response(@grid_artifact)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /grid_artifacts/:id
|
||||||
|
def destroy
|
||||||
|
if @grid_artifact.destroy
|
||||||
|
render json: GridArtifactBlueprint.render(@grid_artifact, view: :destroyed), status: :ok
|
||||||
|
else
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new(@grid_artifact.errors.full_messages.join(', '))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /grid_artifacts/:id/sync
|
||||||
|
def sync
|
||||||
|
unless @grid_artifact.collection_artifact.present?
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new('No collection artifact linked')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@grid_artifact.sync_from_collection!
|
||||||
|
render json: GridArtifactBlueprint.render(@grid_artifact.reload,
|
||||||
|
root: :grid_artifact,
|
||||||
|
view: :nested)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_grid_artifact
|
||||||
|
@grid_artifact = GridArtifact.find_by(id: params[:id])
|
||||||
|
render_not_found_response('grid_artifact') unless @grid_artifact
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_party
|
||||||
|
@party = if @grid_artifact
|
||||||
|
@grid_artifact.grid_character.party
|
||||||
|
else
|
||||||
|
Party.find_by(id: params[:party_id])
|
||||||
|
end
|
||||||
|
render_not_found_response('party') unless @party
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_grid_character
|
||||||
|
@grid_character = GridCharacter.find_by(id: params.dig(:grid_artifact, :grid_character_id))
|
||||||
|
render_not_found_response('grid_character') unless @grid_character
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_artifact
|
||||||
|
artifact_id = params.dig(:grid_artifact, :artifact_id)
|
||||||
|
@artifact = Artifact.find_by(id: artifact_id)
|
||||||
|
render_not_found_response('artifact') unless @artifact
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_party_edit!
|
||||||
|
if @party.user.present?
|
||||||
|
authorize_user_party
|
||||||
|
else
|
||||||
|
authorize_anonymous_party
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_user_party
|
||||||
|
return if current_user.present? && @party.user == current_user
|
||||||
|
|
||||||
|
render_unauthorized_response
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_anonymous_party
|
||||||
|
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
|
||||||
|
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
|
||||||
|
return if valid_edit_key?(provided_edit_key, party_edit_key)
|
||||||
|
|
||||||
|
render_unauthorized_response
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_edit_key?(provided_edit_key, party_edit_key)
|
||||||
|
provided_edit_key.present? &&
|
||||||
|
provided_edit_key.bytesize == party_edit_key.bytesize &&
|
||||||
|
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def grid_artifact_params
|
||||||
|
params.require(:grid_artifact).permit(
|
||||||
|
:grid_character_id, :artifact_id, :collection_artifact_id,
|
||||||
|
:element, :proficiency, :level, :reroll_slot,
|
||||||
|
skill1: %i[modifier strength level],
|
||||||
|
skill2: %i[modifier strength level],
|
||||||
|
skill3: %i[modifier strength level],
|
||||||
|
skill4: %i[modifier strength level]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -13,10 +13,12 @@ module Api
|
||||||
#
|
#
|
||||||
# @see Api::V1::ApiController for shared API behavior.
|
# @see Api::V1::ApiController for shared API behavior.
|
||||||
class GridCharactersController < Api::V1::ApiController
|
class GridCharactersController < Api::V1::ApiController
|
||||||
before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve]
|
include IdResolvable
|
||||||
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
|
|
||||||
|
before_action :find_grid_character, only: %i[update update_uncap_level update_position destroy resolve sync]
|
||||||
|
before_action :find_party, only: %i[create resolve update update_uncap_level update_position swap destroy sync]
|
||||||
before_action :find_incoming_character, only: :create
|
before_action :find_incoming_character, only: :create
|
||||||
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
|
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level update_position swap destroy sync]
|
||||||
|
|
||||||
##
|
##
|
||||||
# Creates a new grid character.
|
# Creates a new grid character.
|
||||||
|
|
@ -80,17 +82,99 @@ module Api
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def update_uncap_level
|
def update_uncap_level
|
||||||
@grid_character.uncap_level = character_params[:uncap_level]
|
@grid_character.uncap_level = character_params[:uncap_level]
|
||||||
@grid_character.transcendence_step = character_params[:transcendence_step]
|
@grid_character.transcendence_step = character_params[:transcendence_step] || 0
|
||||||
|
|
||||||
if @grid_character.save
|
if @grid_character.save
|
||||||
render json: GridCharacterBlueprint.render(@grid_character,
|
render json: GridCharacterBlueprint.render(@grid_character,
|
||||||
root: :grid_character,
|
root: :grid_character,
|
||||||
view: :nested)
|
view: :uncap)
|
||||||
else
|
else
|
||||||
render_validation_error_response(@grid_character)
|
render_validation_error_response(@grid_character)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Updates the position of a GridCharacter.
|
||||||
|
#
|
||||||
|
# Moves a grid character to a new position, maintaining sequential filling for main slots.
|
||||||
|
# Validates that the target position is empty and within allowed bounds.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def update_position
|
||||||
|
new_position = position_params[:position].to_i
|
||||||
|
new_container = position_params[:container]
|
||||||
|
|
||||||
|
# Validate position bounds (0-4 main, 5-6 extra)
|
||||||
|
unless valid_character_position?(new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for character")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if target position is occupied
|
||||||
|
if GridCharacter.exists?(party_id: @party.id, position: new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
old_position = @grid_character.position
|
||||||
|
@grid_character.position = new_position
|
||||||
|
|
||||||
|
# Compact positions if needed (for main slots)
|
||||||
|
reordered = compact_character_positions if should_compact_characters?(old_position, new_position)
|
||||||
|
|
||||||
|
if @grid_character.save
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
grid_character: GridCharacterBlueprint.render_as_hash(@grid_character.reload, view: :nested),
|
||||||
|
reordered: reordered || false
|
||||||
|
}, status: :ok
|
||||||
|
else
|
||||||
|
render_validation_error_response(@grid_character)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Swaps positions between two GridCharacters.
|
||||||
|
#
|
||||||
|
# Exchanges the positions of two grid characters within the same party.
|
||||||
|
# Both characters must belong to the same party.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def swap
|
||||||
|
source_id = swap_params[:source_id]
|
||||||
|
target_id = swap_params[:target_id]
|
||||||
|
|
||||||
|
source = GridCharacter.find_by(id: source_id, party_id: @party.id)
|
||||||
|
target = GridCharacter.find_by(id: target_id, party_id: @party.id)
|
||||||
|
|
||||||
|
unless source && target
|
||||||
|
return render_not_found_response('grid_character')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Perform the swap
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
temp_position = -999
|
||||||
|
source_pos = source.position
|
||||||
|
target_pos = target.position
|
||||||
|
|
||||||
|
source.update!(position: temp_position)
|
||||||
|
target.update!(position: source_pos)
|
||||||
|
source.update!(position: target_pos)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
swapped: {
|
||||||
|
source: GridCharacterBlueprint.render_as_hash(source.reload, view: :nested),
|
||||||
|
target: GridCharacterBlueprint.render_as_hash(target.reload, view: :nested)
|
||||||
|
}
|
||||||
|
}, status: :ok
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_validation_error_response(e.record)
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Resolves conflicts for grid characters.
|
# Resolves conflicts for grid characters.
|
||||||
#
|
#
|
||||||
|
|
@ -100,7 +184,7 @@ module Api
|
||||||
#
|
#
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def resolve
|
def resolve
|
||||||
incoming = Character.find_by(id: resolve_params[:incoming])
|
incoming = find_by_any_id(Character, resolve_params[:incoming])
|
||||||
render_not_found_response('character') and return unless incoming
|
render_not_found_response('character') and return unless incoming
|
||||||
|
|
||||||
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
|
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
|
||||||
|
|
@ -110,22 +194,11 @@ module Api
|
||||||
existing.destroy
|
existing.destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compute the default uncap level based on the incoming character's flags.
|
|
||||||
if incoming.special
|
|
||||||
uncap_level = 3
|
|
||||||
uncap_level = 5 if incoming.ulb
|
|
||||||
uncap_level = 4 if incoming.flb
|
|
||||||
else
|
|
||||||
uncap_level = 4
|
|
||||||
uncap_level = 6 if incoming.ulb
|
|
||||||
uncap_level = 5 if incoming.flb
|
|
||||||
end
|
|
||||||
|
|
||||||
grid_character = GridCharacter.create!(
|
grid_character = GridCharacter.create!(
|
||||||
party_id: @party.id,
|
party_id: @party.id,
|
||||||
character_id: incoming.id,
|
character_id: incoming.id,
|
||||||
position: resolve_params[:position],
|
position: resolve_params[:position],
|
||||||
uncap_level: uncap_level
|
uncap_level: compute_max_uncap_level(incoming)
|
||||||
)
|
)
|
||||||
render json: GridCharacterBlueprint.render(grid_character,
|
render json: GridCharacterBlueprint.render(grid_character,
|
||||||
root: :grid_character,
|
root: :grid_character,
|
||||||
|
|
@ -144,7 +217,33 @@ module Api
|
||||||
|
|
||||||
return render_not_found_response('grid_character') if grid_character.nil?
|
return render_not_found_response('grid_character') if grid_character.nil?
|
||||||
|
|
||||||
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy
|
if grid_character.destroy
|
||||||
|
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed)
|
||||||
|
else
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new(grid_character.errors.full_messages.join(', '))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Syncs a grid character from its linked collection character.
|
||||||
|
#
|
||||||
|
# Copies all customizations from the collection character to this grid character.
|
||||||
|
# Returns 422 if no collection character is linked.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def sync
|
||||||
|
unless @grid_character.collection_character.present?
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new('No collection character linked')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@grid_character.sync_from_collection!
|
||||||
|
render json: GridCharacterBlueprint.render(@grid_character.reload,
|
||||||
|
root: :grid_character,
|
||||||
|
view: :nested)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -158,7 +257,8 @@ module Api
|
||||||
grid_character = GridCharacter.new(
|
grid_character = GridCharacter.new(
|
||||||
character_params.except(:rings, :awakening).merge(
|
character_params.except(:rings, :awakening).merge(
|
||||||
party_id: @party.id,
|
party_id: @party.id,
|
||||||
character_id: @incoming_character.id
|
character_id: @incoming_character.id,
|
||||||
|
uncap_level: compute_max_uncap_level(@incoming_character)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assign_transformed_attributes(grid_character, processed_params)
|
assign_transformed_attributes(grid_character, processed_params)
|
||||||
|
|
@ -166,17 +266,37 @@ module Api
|
||||||
grid_character
|
grid_character
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Computes the maximum uncap level for a character based on its flags.
|
||||||
|
#
|
||||||
|
# Special characters (limited/seasonal) have a different uncap progression:
|
||||||
|
# - Base: 3, FLB: 4, ULB: 5
|
||||||
|
# Regular characters:
|
||||||
|
# - Base: 4, FLB: 5, ULB: 6
|
||||||
|
#
|
||||||
|
# @param character [Character] the character to compute max uncap for.
|
||||||
|
# @return [Integer] the maximum uncap level.
|
||||||
|
def compute_max_uncap_level(character)
|
||||||
|
if character.special
|
||||||
|
character.ulb ? 5 : character.flb ? 4 : 3
|
||||||
|
else
|
||||||
|
character.ulb ? 6 : character.flb ? 5 : 4
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Assigns raw attributes from the original parameters to the grid character.
|
# Assigns raw attributes from the original parameters to the grid character.
|
||||||
#
|
#
|
||||||
# These attributes (like new_rings and new_awakening) are used by model callbacks.
|
# These attributes (like new_rings and new_awakening) are used by model callbacks.
|
||||||
|
# Note: We exclude :character_id and :party_id because they are already set correctly
|
||||||
|
# in build_new_grid_character using the resolved UUIDs, not the raw granblue_id from params.
|
||||||
#
|
#
|
||||||
# @param grid_character [GridCharacter] the grid character instance.
|
# @param grid_character [GridCharacter] the grid character instance.
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def assign_raw_attributes(grid_character)
|
def assign_raw_attributes(grid_character)
|
||||||
grid_character.new_rings = character_params[:rings] if character_params[:rings].present?
|
grid_character.new_rings = character_params[:rings] if character_params[:rings].present?
|
||||||
grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present?
|
grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present?
|
||||||
grid_character.assign_attributes(character_params.except(:rings, :awakening))
|
grid_character.assign_attributes(character_params.except(:rings, :awakening, :character_id, :party_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -315,8 +435,12 @@ module Api
|
||||||
#
|
#
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def find_incoming_character
|
def find_incoming_character
|
||||||
@incoming_character = Character.find_by(id: character_params[:character_id])
|
character_id = character_params[:character_id]
|
||||||
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character
|
@incoming_character = find_by_any_id(Character, character_id)
|
||||||
|
|
||||||
|
unless @incoming_character
|
||||||
|
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -374,6 +498,45 @@ module Api
|
||||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Validates if a character position is valid.
|
||||||
|
#
|
||||||
|
# @param position [Integer] the position to validate.
|
||||||
|
# @return [Boolean] true if the position is valid; false otherwise.
|
||||||
|
def valid_character_position?(position)
|
||||||
|
# Main slots (0-4), extra slots (5-7) for unlimited raids
|
||||||
|
(0..7).cover?(position)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Determines if character positions should be compacted.
|
||||||
|
#
|
||||||
|
# @param old_position [Integer] the old position.
|
||||||
|
# @param new_position [Integer] the new position.
|
||||||
|
# @return [Boolean] true if compaction is needed; false otherwise.
|
||||||
|
def should_compact_characters?(old_position, new_position)
|
||||||
|
# Compact if moving from main slots (0-4) to extra (5-7) or vice versa
|
||||||
|
main_to_extra = (0..4).cover?(old_position) && (5..7).cover?(new_position)
|
||||||
|
extra_to_main = (5..7).cover?(old_position) && (0..4).cover?(new_position)
|
||||||
|
main_to_extra || extra_to_main
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Compacts character positions to maintain sequential filling.
|
||||||
|
#
|
||||||
|
# @return [Boolean] true if positions were reordered; false otherwise.
|
||||||
|
def compact_character_positions
|
||||||
|
main_characters = @party.characters.where(position: 0..4).order(:position)
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
main_characters.each_with_index do |char, index|
|
||||||
|
char.update!(position: index) if char.position != index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Specifies and permits the allowed character parameters.
|
# Specifies and permits the allowed character parameters.
|
||||||
#
|
#
|
||||||
|
|
@ -383,6 +546,7 @@ module Api
|
||||||
:id,
|
:id,
|
||||||
:party_id,
|
:party_id,
|
||||||
:character_id,
|
:character_id,
|
||||||
|
:collection_character_id,
|
||||||
:position,
|
:position,
|
||||||
:uncap_level,
|
:uncap_level,
|
||||||
:transcendence_step,
|
:transcendence_step,
|
||||||
|
|
@ -393,6 +557,22 @@ module Api
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Specifies and permits the position update parameters.
|
||||||
|
#
|
||||||
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
|
def position_params
|
||||||
|
params.permit(:position, :container)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Specifies and permits the swap parameters.
|
||||||
|
#
|
||||||
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
|
def swap_params
|
||||||
|
params.permit(:source_id, :target_id)
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Specifies and permits the allowed resolve parameters.
|
# Specifies and permits the allowed resolve parameters.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ module Api
|
||||||
#
|
#
|
||||||
# @see Api::V1::ApiController for shared API behavior.
|
# @see Api::V1::ApiController for shared API behavior.
|
||||||
class GridSummonsController < Api::V1::ApiController
|
class GridSummonsController < Api::V1::ApiController
|
||||||
|
include IdResolvable
|
||||||
|
|
||||||
attr_reader :party, :incoming_summon
|
attr_reader :party, :incoming_summon
|
||||||
|
|
||||||
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy]
|
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon update_position resolve destroy sync]
|
||||||
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy]
|
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon update_position swap resolve destroy sync]
|
||||||
before_action :find_incoming_summon, only: :create
|
before_action :find_incoming_summon, only: :create
|
||||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
|
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon update_position swap destroy sync]
|
||||||
|
|
||||||
##
|
##
|
||||||
# Creates a new grid summon.
|
# Creates a new grid summon.
|
||||||
|
|
@ -28,10 +30,9 @@ module Api
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def create
|
def create
|
||||||
# Build a new grid summon using permitted parameters merged with party and summon IDs.
|
# Build a new grid summon using permitted parameters merged with party and summon IDs.
|
||||||
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
|
# Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
|
||||||
# if it hasn't already been provided.
|
|
||||||
grid_summon = build_grid_summon.tap do |gs|
|
grid_summon = build_grid_summon.tap do |gs|
|
||||||
gs.uncap_level ||= max_uncap_level(gs)
|
gs.uncap_level = max_uncap_level(gs.summon)
|
||||||
end
|
end
|
||||||
|
|
||||||
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
|
||||||
|
|
@ -82,7 +83,7 @@ module Api
|
||||||
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
|
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
|
||||||
|
|
||||||
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
|
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
|
||||||
render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon)
|
render json: GridSummonBlueprint.render(@grid_summon, view: :uncap, root: :grid_summon)
|
||||||
else
|
else
|
||||||
render_validation_error_response(@grid_summon)
|
render_validation_error_response(@grid_summon)
|
||||||
end
|
end
|
||||||
|
|
@ -114,6 +115,97 @@ module Api
|
||||||
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
|
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Updates the position of a GridSummon.
|
||||||
|
#
|
||||||
|
# Moves a grid summon to a new position, optionally changing its container.
|
||||||
|
# Validates that the target position is empty and within allowed bounds.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def update_position
|
||||||
|
new_position = position_params[:position].to_i
|
||||||
|
new_container = position_params[:container]
|
||||||
|
|
||||||
|
# Validate position bounds (-1 main, 0-3 sub, 4-5 subaura, 6 friend)
|
||||||
|
unless valid_summon_position?(new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for summon")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if position is restricted (main summon, friend)
|
||||||
|
if restricted_summon_position?(new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::InvalidPositionError.new("Cannot move summon to restricted position #{new_position}")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if target position is occupied
|
||||||
|
if GridSummon.exists?(party_id: @party.id, position: new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@grid_summon.position = new_position
|
||||||
|
|
||||||
|
if @grid_summon.save
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
grid_summon: GridSummonBlueprint.render_as_hash(@grid_summon.reload, view: :nested)
|
||||||
|
}, status: :ok
|
||||||
|
else
|
||||||
|
render_validation_error_response(@grid_summon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Swaps positions between two GridSummons.
|
||||||
|
#
|
||||||
|
# Exchanges the positions of two grid summons within the same party.
|
||||||
|
# Both summons must belong to the same party and not be in restricted positions.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def swap
|
||||||
|
source_id = swap_params[:source_id]
|
||||||
|
target_id = swap_params[:target_id]
|
||||||
|
|
||||||
|
source = GridSummon.find_by(id: source_id, party_id: @party.id)
|
||||||
|
target = GridSummon.find_by(id: target_id, party_id: @party.id)
|
||||||
|
|
||||||
|
unless source && target
|
||||||
|
return render_not_found_response('grid_summon')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if either position is restricted
|
||||||
|
if restricted_summon_position?(source.position) || restricted_summon_position?(target.position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::InvalidPositionError.new("Cannot swap summons in restricted positions")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Perform the swap
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
temp_position = -999
|
||||||
|
source_pos = source.position
|
||||||
|
target_pos = target.position
|
||||||
|
|
||||||
|
source.update!(position: temp_position)
|
||||||
|
target.update!(position: source_pos)
|
||||||
|
source.update!(position: target_pos)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
swapped: {
|
||||||
|
source: GridSummonBlueprint.render_as_hash(source.reload, view: :nested),
|
||||||
|
target: GridSummonBlueprint.render_as_hash(target.reload, view: :nested)
|
||||||
|
}
|
||||||
|
}, status: :ok
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_validation_error_response(e.record)
|
||||||
|
end
|
||||||
|
|
||||||
#
|
#
|
||||||
# Destroys a grid summon.
|
# Destroys a grid summon.
|
||||||
#
|
#
|
||||||
|
|
@ -127,7 +219,30 @@ module Api
|
||||||
|
|
||||||
return render_not_found_response('grid_summon') if grid_summon.nil?
|
return render_not_found_response('grid_summon') if grid_summon.nil?
|
||||||
|
|
||||||
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy
|
if grid_summon.destroy
|
||||||
|
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok
|
||||||
|
else
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new(grid_summon.errors.full_messages.join(', '))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Syncs a grid summon from its linked collection summon.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def sync
|
||||||
|
unless @grid_summon.collection_summon.present?
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new('No collection summon linked')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@grid_summon.sync_from_collection!
|
||||||
|
render json: GridSummonBlueprint.render(@grid_summon.reload,
|
||||||
|
root: :grid_summon,
|
||||||
|
view: :nested)
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -214,7 +329,7 @@ module Api
|
||||||
#
|
#
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def find_incoming_summon
|
def find_incoming_summon
|
||||||
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
|
@incoming_summon = find_by_any_id(Summon, summon_params[:summon_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -331,13 +446,50 @@ module Api
|
||||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Validates if a summon position is valid.
|
||||||
|
#
|
||||||
|
# @param position [Integer] the position to validate.
|
||||||
|
# @return [Boolean] true if the position is valid; false otherwise.
|
||||||
|
def valid_summon_position?(position)
|
||||||
|
# Main (-1), sub slots (0-3), subaura (4-5), friend (6)
|
||||||
|
position == -1 || (0..6).cover?(position)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Checks if a summon position is restricted (cannot be drag-drop target).
|
||||||
|
#
|
||||||
|
# @param position [Integer] the position to check.
|
||||||
|
# @return [Boolean] true if the position is restricted; false otherwise.
|
||||||
|
def restricted_summon_position?(position)
|
||||||
|
# Main summon (-1) and friend summon (6) are restricted
|
||||||
|
position == -1 || position == 6
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Defines and permits the whitelisted parameters for a grid summon.
|
# Defines and permits the whitelisted parameters for a grid summon.
|
||||||
#
|
#
|
||||||
# @return [ActionController::Parameters] The permitted parameters.
|
# @return [ActionController::Parameters] The permitted parameters.
|
||||||
def summon_params
|
def summon_params
|
||||||
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend,
|
params.require(:summon).permit(:id, :party_id, :summon_id, :collection_summon_id,
|
||||||
:quick_summon, :uncap_level, :transcendence_step)
|
:position, :main, :friend, :quick_summon,
|
||||||
|
:uncap_level, :transcendence_step)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Specifies and permits the position update parameters.
|
||||||
|
#
|
||||||
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
|
def position_params
|
||||||
|
params.permit(:position, :container)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Specifies and permits the swap parameters.
|
||||||
|
#
|
||||||
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
|
def swap_params
|
||||||
|
params.permit(:source_id, :target_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ module Api
|
||||||
#
|
#
|
||||||
# @see Api::V1::ApiController for shared API behavior.
|
# @see Api::V1::ApiController for shared API behavior.
|
||||||
class GridWeaponsController < Api::V1::ApiController
|
class GridWeaponsController < Api::V1::ApiController
|
||||||
before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy]
|
include IdResolvable
|
||||||
before_action :find_party, only: %i[create update update_uncap_level resolve destroy]
|
|
||||||
|
before_action :find_grid_weapon, only: %i[update update_uncap_level update_position resolve destroy sync]
|
||||||
|
before_action :find_party, only: %i[create update update_uncap_level update_position swap resolve destroy sync]
|
||||||
before_action :find_incoming_weapon, only: %i[create resolve]
|
before_action :find_incoming_weapon, only: %i[create resolve]
|
||||||
before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
|
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_position swap resolve destroy sync]
|
||||||
|
|
||||||
##
|
##
|
||||||
# Creates a new GridWeapon.
|
# Creates a new GridWeapon.
|
||||||
|
|
@ -26,10 +28,13 @@ module Api
|
||||||
def create
|
def create
|
||||||
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
|
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
|
||||||
|
|
||||||
|
position = weapon_params[:position]
|
||||||
|
collection_weapon_id = weapon_params[:collection_weapon_id]
|
||||||
grid_weapon = GridWeapon.new(
|
grid_weapon = GridWeapon.new(
|
||||||
weapon_params.merge(
|
weapon_params.merge(
|
||||||
party_id: @party.id,
|
party_id: @party.id,
|
||||||
weapon_id: @incoming_weapon.id
|
weapon_id: @incoming_weapon.id,
|
||||||
|
uncap_level: compute_default_uncap(@incoming_weapon, position, collection_weapon_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -72,13 +77,95 @@ module Api
|
||||||
requested_uncap = weapon_params[:uncap_level].to_i
|
requested_uncap = weapon_params[:uncap_level].to_i
|
||||||
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
|
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
|
||||||
|
|
||||||
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i)
|
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: (weapon_params[:transcendence_step] || 0).to_i)
|
||||||
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
|
render json: GridWeaponBlueprint.render(@grid_weapon, view: :uncap, root: :grid_weapon), status: :ok
|
||||||
else
|
else
|
||||||
render_validation_error_response(@grid_weapon)
|
render_validation_error_response(@grid_weapon)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Updates the position of a GridWeapon.
|
||||||
|
#
|
||||||
|
# Moves a grid weapon to a new position, optionally changing its container.
|
||||||
|
# Validates that the target position is empty and within allowed bounds.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def update_position
|
||||||
|
new_position = position_params[:position].to_i
|
||||||
|
new_container = position_params[:container]
|
||||||
|
|
||||||
|
# Validate position bounds
|
||||||
|
unless valid_weapon_position?(new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for weapon")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if target position is occupied
|
||||||
|
if GridWeapon.exists?(party_id: @party.id, position: new_position)
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update position
|
||||||
|
old_position = @grid_weapon.position
|
||||||
|
@grid_weapon.position = new_position
|
||||||
|
|
||||||
|
# Update party attributes if needed
|
||||||
|
update_party_attributes_for_position(@grid_weapon, new_position)
|
||||||
|
|
||||||
|
if @grid_weapon.save
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party, view: :full),
|
||||||
|
grid_weapon: GridWeaponBlueprint.render_as_hash(@grid_weapon, view: :full)
|
||||||
|
}, status: :ok
|
||||||
|
else
|
||||||
|
render_validation_error_response(@grid_weapon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Swaps positions between two GridWeapons.
|
||||||
|
#
|
||||||
|
# Exchanges the positions of two grid weapons within the same party.
|
||||||
|
# Both weapons must belong to the same party and be valid for swapping.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def swap
|
||||||
|
source_id = swap_params[:source_id]
|
||||||
|
target_id = swap_params[:target_id]
|
||||||
|
|
||||||
|
source = GridWeapon.find_by(id: source_id, party_id: @party.id)
|
||||||
|
target = GridWeapon.find_by(id: target_id, party_id: @party.id)
|
||||||
|
|
||||||
|
unless source && target
|
||||||
|
return render_not_found_response('grid_weapon')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Perform the swap
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
temp_position = -999
|
||||||
|
source_pos = source.position
|
||||||
|
target_pos = target.position
|
||||||
|
|
||||||
|
source.update!(position: temp_position)
|
||||||
|
target.update!(position: source_pos)
|
||||||
|
source.update!(position: target_pos)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
swapped: {
|
||||||
|
source: GridWeaponBlueprint.render_as_hash(source.reload, view: :full),
|
||||||
|
target: GridWeaponBlueprint.render_as_hash(target.reload, view: :full)
|
||||||
|
}
|
||||||
|
}, status: :ok
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_validation_error_response(e.record)
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Resolves conflicts by removing conflicting grid weapons and creating a new one.
|
# Resolves conflicts by removing conflicting grid weapons and creating a new one.
|
||||||
#
|
#
|
||||||
|
|
@ -88,7 +175,7 @@ module Api
|
||||||
#
|
#
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def resolve
|
def resolve
|
||||||
incoming = Weapon.find_by(id: resolve_params[:incoming])
|
incoming = find_by_any_id(Weapon, resolve_params[:incoming])
|
||||||
conflicting_ids = resolve_params[:conflicting]
|
conflicting_ids = resolve_params[:conflicting]
|
||||||
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
|
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
|
||||||
|
|
||||||
|
|
@ -101,11 +188,13 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
|
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
|
||||||
new_uncap = compute_default_uncap(incoming)
|
# For extra positions, force ULB for weapons with extra-capable series.
|
||||||
|
position = resolve_params[:position]
|
||||||
|
new_uncap = compute_default_uncap(incoming, position)
|
||||||
grid_weapon = GridWeapon.create!(
|
grid_weapon = GridWeapon.create!(
|
||||||
party_id: @party.id,
|
party_id: @party.id,
|
||||||
weapon_id: incoming.id,
|
weapon_id: incoming.id,
|
||||||
position: resolve_params[:position],
|
position: position,
|
||||||
uncap_level: new_uncap,
|
uncap_level: new_uncap,
|
||||||
transcendence_step: 0
|
transcendence_step: 0
|
||||||
)
|
)
|
||||||
|
|
@ -128,7 +217,30 @@ module Api
|
||||||
|
|
||||||
return render_not_found_response('grid_weapon') if grid_weapon.nil?
|
return render_not_found_response('grid_weapon') if grid_weapon.nil?
|
||||||
|
|
||||||
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
|
if grid_weapon.destroy
|
||||||
|
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok
|
||||||
|
else
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new(grid_weapon.errors.full_messages.join(', '))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Syncs a grid weapon from its linked collection weapon.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def sync
|
||||||
|
unless @grid_weapon.collection_weapon.present?
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new('No collection weapon linked')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@grid_weapon.sync_from_collection!
|
||||||
|
render json: GridWeaponBlueprint.render(@grid_weapon.reload,
|
||||||
|
root: :grid_weapon,
|
||||||
|
view: :full)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -154,23 +266,38 @@ module Api
|
||||||
# Computes the default uncap level for an incoming weapon.
|
# Computes the default uncap level for an incoming weapon.
|
||||||
#
|
#
|
||||||
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
|
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
|
||||||
|
# For extra positions (9-11), weapons with extra_prerequisite set will be forced to that uncap level.
|
||||||
|
# This logic is skipped for collection weapons which should retain their actual uncap level.
|
||||||
#
|
#
|
||||||
# @param incoming [Weapon] the incoming weapon.
|
# @param incoming [Weapon] the incoming weapon.
|
||||||
|
# @param position [Integer] the target position (optional).
|
||||||
|
# @param collection_weapon_id [String] the collection weapon ID if linking from collection (optional).
|
||||||
# @return [Integer] the default uncap level.
|
# @return [Integer] the default uncap level.
|
||||||
def compute_default_uncap(incoming)
|
def compute_default_uncap(incoming, position = nil, collection_weapon_id = nil)
|
||||||
compute_max_uncap_level(incoming)
|
max_uncap = compute_max_uncap_level(incoming)
|
||||||
|
|
||||||
|
# Skip prerequisite logic for collection weapons - use their actual uncap level
|
||||||
|
return max_uncap if collection_weapon_id.present?
|
||||||
|
|
||||||
|
# Extra positions require minimum uncap for weapons with extra_prerequisite set
|
||||||
|
if position && GridWeapon::EXTRA_POSITIONS.include?(position.to_i) &&
|
||||||
|
incoming.extra_prerequisite.present?
|
||||||
|
return [incoming.extra_prerequisite, max_uncap].min
|
||||||
|
end
|
||||||
|
|
||||||
|
max_uncap
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Normalizes the AX modifier fields for the weapon parameters.
|
# Normalizes the AX modifier fields for the weapon parameters.
|
||||||
#
|
#
|
||||||
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
|
# Sets ax_modifier1_id and ax_modifier2_id to nil if their integer values equal -1.
|
||||||
#
|
#
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def normalize_ax_fields!
|
def normalize_ax_fields!
|
||||||
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
|
params[:weapon][:ax_modifier1_id] = nil if weapon_params[:ax_modifier1_id].to_i == -1
|
||||||
|
params[:weapon][:ax_modifier2_id] = nil if weapon_params[:ax_modifier2_id].to_i == -1
|
||||||
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
|
params[:weapon][:befoulment_modifier_id] = nil if weapon_params[:befoulment_modifier_id].to_i == -1
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -280,7 +407,7 @@ module Api
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def find_incoming_weapon
|
def find_incoming_weapon
|
||||||
if params.dig(:weapon, :weapon_id).present?
|
if params.dig(:weapon, :weapon_id).present?
|
||||||
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
|
@incoming_weapon = find_by_any_id(Weapon, params.dig(:weapon, :weapon_id))
|
||||||
render_not_found_response('weapon') unless @incoming_weapon
|
render_not_found_response('weapon') unless @incoming_weapon
|
||||||
else
|
else
|
||||||
@incoming_weapon = nil
|
@incoming_weapon = nil
|
||||||
|
|
@ -353,20 +480,63 @@ module Api
|
||||||
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Validates if a weapon position is valid.
|
||||||
|
#
|
||||||
|
# @param position [Integer] the position to validate.
|
||||||
|
# @return [Boolean] true if the position is valid; false otherwise.
|
||||||
|
def valid_weapon_position?(position)
|
||||||
|
# Mainhand (-1), grid slots (0-8), extra slots (9-11)
|
||||||
|
position == -1 || (0..11).cover?(position)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Updates party attributes based on the weapon's new position.
|
||||||
|
#
|
||||||
|
# @param grid_weapon [GridWeapon] the grid weapon being moved.
|
||||||
|
# @param new_position [Integer] the new position.
|
||||||
|
# @return [void]
|
||||||
|
def update_party_attributes_for_position(grid_weapon, new_position)
|
||||||
|
if new_position == -1
|
||||||
|
@party.element = grid_weapon.weapon.element
|
||||||
|
@party.save!
|
||||||
|
elsif GridWeapon::EXTRA_POSITIONS.include?(new_position)
|
||||||
|
@party.extra = true
|
||||||
|
@party.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Specifies and permits the allowed weapon parameters.
|
# Specifies and permits the allowed weapon parameters.
|
||||||
#
|
#
|
||||||
# @return [ActionController::Parameters] the permitted parameters.
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
def weapon_params
|
def weapon_params
|
||||||
params.require(:weapon).permit(
|
params.require(:weapon).permit(
|
||||||
:id, :party_id, :weapon_id,
|
:id, :party_id, :weapon_id, :collection_weapon_id,
|
||||||
:position, :mainhand, :uncap_level, :transcendence_step, :element,
|
:position, :mainhand, :uncap_level, :transcendence_step, :element,
|
||||||
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id,
|
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id,
|
||||||
:ax_modifier1, :ax_modifier2, :ax_strength1, :ax_strength2,
|
:ax_modifier1_id, :ax_modifier2_id, :ax_strength1, :ax_strength2,
|
||||||
|
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
|
||||||
:awakening_id, :awakening_level
|
:awakening_id, :awakening_level
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Specifies and permits the position update parameters.
|
||||||
|
#
|
||||||
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
|
def position_params
|
||||||
|
params.permit(:position, :container)
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Specifies and permits the swap parameters.
|
||||||
|
#
|
||||||
|
# @return [ActionController::Parameters] the permitted parameters.
|
||||||
|
def swap_params
|
||||||
|
params.permit(:source_id, :target_id)
|
||||||
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
# Specifies and permits the resolve parameters.
|
# Specifies and permits the resolve parameters.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
60
app/controllers/api/v1/gw_crew_scores_controller.rb
Normal file
60
app/controllers/api/v1/gw_crew_scores_controller.rb
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GwCrewScoresController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew
|
||||||
|
before_action :authorize_crew_officer!
|
||||||
|
before_action :set_participation
|
||||||
|
before_action :set_score, only: %i[update destroy]
|
||||||
|
|
||||||
|
# POST /crew/gw_participations/:gw_participation_id/crew_scores
|
||||||
|
def create
|
||||||
|
score = @participation.gw_crew_scores.build(score_params)
|
||||||
|
|
||||||
|
if score.save
|
||||||
|
render json: GwCrewScoreBlueprint.render(score, root: :gw_crew_score), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(score)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /crew/gw_participations/:gw_participation_id/crew_scores/:id
|
||||||
|
def update
|
||||||
|
if @score.update(score_params)
|
||||||
|
render json: GwCrewScoreBlueprint.render(@score, root: :gw_crew_score)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@score)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /crew/gw_participations/:gw_participation_id/crew_scores/:id
|
||||||
|
def destroy
|
||||||
|
@score.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = current_user.crew
|
||||||
|
raise CrewErrors::NotInCrewError unless @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_participation
|
||||||
|
@participation = @crew.crew_gw_participations.find(params[:gw_participation_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_score
|
||||||
|
@score = @participation.gw_crew_scores.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_params
|
||||||
|
params.require(:crew_score).permit(:round, :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
67
app/controllers/api/v1/gw_events_controller.rb
Normal file
67
app/controllers/api/v1/gw_events_controller.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GwEventsController < Api::V1::ApiController
|
||||||
|
before_action :restrict_access, only: %i[create update]
|
||||||
|
before_action :require_admin!, only: %i[create update]
|
||||||
|
before_action :set_event, only: %i[show update]
|
||||||
|
|
||||||
|
# GET /gw_events
|
||||||
|
def index
|
||||||
|
events = GwEvent.order(start_date: :desc)
|
||||||
|
|
||||||
|
# If user has a crew, include participation data for each event
|
||||||
|
participations_by_event = {}
|
||||||
|
if current_user&.crew
|
||||||
|
participations = current_user.crew.crew_gw_participations.includes(:gw_individual_scores)
|
||||||
|
participations.each do |p|
|
||||||
|
participations_by_event[p.gw_event_id] = p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: GwEventBlueprint.render(events, root: :gw_events, participations: participations_by_event)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /gw_events/:id
|
||||||
|
def show
|
||||||
|
participation = current_user&.crew&.crew_gw_participations&.find_by(gw_event: @event)
|
||||||
|
render json: GwEventBlueprint.render(@event, view: :with_participation, participation: participation, root: :gw_event)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /gw_events (admin only)
|
||||||
|
def create
|
||||||
|
event = GwEvent.new(event_params)
|
||||||
|
|
||||||
|
if event.save
|
||||||
|
render json: GwEventBlueprint.render(event, root: :gw_event), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /gw_events/:id (admin only)
|
||||||
|
def update
|
||||||
|
if @event.update(event_params)
|
||||||
|
render json: GwEventBlueprint.render(@event, root: :gw_event)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_event
|
||||||
|
@event = GwEvent.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def event_params
|
||||||
|
params.require(:gw_event).permit(:element, :start_date, :end_date, :event_number)
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_admin!
|
||||||
|
raise Api::V1::UnauthorizedError unless current_user&.admin?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
156
app/controllers/api/v1/gw_individual_scores_controller.rb
Normal file
156
app/controllers/api/v1/gw_individual_scores_controller.rb
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class GwIndividualScoresController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew
|
||||||
|
before_action :authorize_crew_member!
|
||||||
|
before_action :set_participation, except: %i[create_by_event batch_by_event]
|
||||||
|
before_action :set_or_create_participation_by_event, only: %i[create_by_event batch_by_event]
|
||||||
|
before_action :set_score, only: %i[update destroy]
|
||||||
|
|
||||||
|
# POST /crew/gw_participations/:gw_participation_id/individual_scores
|
||||||
|
def create
|
||||||
|
# Members can only record their own scores, officers can record anyone's
|
||||||
|
membership_id = score_params[:crew_membership_id]
|
||||||
|
unless can_record_score_for?(membership_id)
|
||||||
|
raise Api::V1::UnauthorizedError
|
||||||
|
end
|
||||||
|
|
||||||
|
score = @participation.gw_individual_scores.build(score_params)
|
||||||
|
score.recorded_by = current_user
|
||||||
|
|
||||||
|
if score.save
|
||||||
|
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score, current_user: current_user), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(score)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /crew/gw_participations/:gw_participation_id/individual_scores/:id
|
||||||
|
def update
|
||||||
|
unless can_record_score_for?(@score.crew_membership_id)
|
||||||
|
raise Api::V1::UnauthorizedError
|
||||||
|
end
|
||||||
|
|
||||||
|
if @score.update(score_params.except(:crew_membership_id))
|
||||||
|
render json: GwIndividualScoreBlueprint.render(@score, view: :with_member, root: :individual_score, current_user: current_user)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@score)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /crew/gw_participations/:gw_participation_id/individual_scores/:id
|
||||||
|
def destroy
|
||||||
|
unless can_record_score_for?(@score.crew_membership_id)
|
||||||
|
raise Api::V1::UnauthorizedError
|
||||||
|
end
|
||||||
|
|
||||||
|
@score.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crew/gw_participations/:gw_participation_id/individual_scores/batch
|
||||||
|
def batch
|
||||||
|
return render_unauthorized_response unless current_user.crew_officer?
|
||||||
|
|
||||||
|
process_batch_scores
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crew/gw_events/:gw_event_id/individual_scores
|
||||||
|
# Auto-creates participation if needed, officers only
|
||||||
|
def create_by_event
|
||||||
|
return render_unauthorized_response unless current_user.crew_officer?
|
||||||
|
|
||||||
|
score = @participation.gw_individual_scores.build(score_params_with_player)
|
||||||
|
score.recorded_by = current_user
|
||||||
|
|
||||||
|
if score.save
|
||||||
|
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score, current_user: current_user), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(score)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crew/gw_events/:gw_event_id/individual_scores/batch
|
||||||
|
# Auto-creates participation if needed, officers only
|
||||||
|
def batch_by_event
|
||||||
|
return render_unauthorized_response unless current_user.crew_officer?
|
||||||
|
|
||||||
|
process_batch_scores
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = current_user.crew
|
||||||
|
raise CrewErrors::NotInCrewError unless @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_participation
|
||||||
|
@participation = @crew.crew_gw_participations.find(params[:gw_participation_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_or_create_participation_by_event
|
||||||
|
event = GwEvent.find(params[:gw_event_id])
|
||||||
|
@participation = @crew.crew_gw_participations.find_or_create_by!(gw_event: event)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_score
|
||||||
|
@score = @participation.gw_individual_scores.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_params
|
||||||
|
params.require(:individual_score).permit(:crew_membership_id, :round, :score, :is_cumulative, :excused, :excuse_reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_params_with_player
|
||||||
|
params.require(:individual_score).permit(:crew_membership_id, :phantom_player_id, :round, :score, :is_cumulative, :excused, :excuse_reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_record_score_for?(membership_id)
|
||||||
|
return true if current_user.crew_officer?
|
||||||
|
|
||||||
|
# Regular members can only record their own scores
|
||||||
|
current_user.active_crew_membership&.id == membership_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_batch_scores
|
||||||
|
scores_params = params.require(:scores)
|
||||||
|
results = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
scores_params.each_with_index do |score_data, index|
|
||||||
|
score = @participation.gw_individual_scores.find_or_initialize_by(
|
||||||
|
crew_membership_id: score_data[:crew_membership_id],
|
||||||
|
phantom_player_id: score_data[:phantom_player_id],
|
||||||
|
round: score_data[:round]
|
||||||
|
)
|
||||||
|
score.assign_attributes(
|
||||||
|
score: score_data[:score],
|
||||||
|
is_cumulative: score_data[:is_cumulative] || false,
|
||||||
|
excused: score_data[:excused] || false,
|
||||||
|
excuse_reason: score_data[:excuse_reason],
|
||||||
|
recorded_by: current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
if score.save
|
||||||
|
results << score
|
||||||
|
else
|
||||||
|
errors << { index: index, errors: score.errors.full_messages }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.empty?
|
||||||
|
render json: GwIndividualScoreBlueprint.render(results, view: :with_member, root: :individual_scores, current_user: current_user), status: :created
|
||||||
|
else
|
||||||
|
render json: { individual_scores: GwIndividualScoreBlueprint.render_as_hash(results, view: :with_member, current_user: current_user), errors: errors },
|
||||||
|
status: :multi_status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -27,6 +27,94 @@ module Api
|
||||||
6 => 5
|
6 => 5
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
# GBF series_id to CharacterSeries slug mapping
|
||||||
|
GBF_SERIES_TO_SLUG = {
|
||||||
|
1 => 'summer',
|
||||||
|
2 => 'yukata',
|
||||||
|
3 => 'valentine',
|
||||||
|
4 => 'halloween',
|
||||||
|
5 => 'holiday',
|
||||||
|
6 => 'zodiac',
|
||||||
|
7 => 'grand',
|
||||||
|
8 => 'fantasy',
|
||||||
|
9 => 'collab',
|
||||||
|
10 => 'eternal',
|
||||||
|
11 => 'evoker',
|
||||||
|
12 => 'saint',
|
||||||
|
13 => 'formal'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# GBF series_id to WeaponSeries slug mapping
|
||||||
|
GBF_WEAPON_SERIES_TO_SLUG = {
|
||||||
|
1 => 'seraphic',
|
||||||
|
2 => 'grand',
|
||||||
|
3 => 'dark-opus',
|
||||||
|
4 => 'revenant',
|
||||||
|
5 => 'primal',
|
||||||
|
6 => 'beast',
|
||||||
|
7 => 'regalia',
|
||||||
|
8 => 'omega',
|
||||||
|
9 => 'olden-primal',
|
||||||
|
10 => 'hollowsky',
|
||||||
|
11 => 'xeno',
|
||||||
|
12 => 'rose',
|
||||||
|
13 => 'ultima',
|
||||||
|
14 => 'bahamut',
|
||||||
|
15 => 'epic',
|
||||||
|
16 => 'cosmos',
|
||||||
|
17 => 'superlative',
|
||||||
|
18 => 'vintage',
|
||||||
|
19 => 'class-champion',
|
||||||
|
20 => 'replica',
|
||||||
|
21 => 'relic',
|
||||||
|
22 => 'rusted',
|
||||||
|
23 => 'sephira',
|
||||||
|
24 => 'vyrmament',
|
||||||
|
25 => 'upgrader',
|
||||||
|
26 => 'astral',
|
||||||
|
27 => 'draconic',
|
||||||
|
28 => 'eternal-splendor',
|
||||||
|
29 => 'ancestral',
|
||||||
|
30 => 'new-world-foundation',
|
||||||
|
31 => 'ennead',
|
||||||
|
32 => 'militis',
|
||||||
|
33 => 'malice',
|
||||||
|
34 => 'menace',
|
||||||
|
35 => 'illustrious',
|
||||||
|
36 => 'proven',
|
||||||
|
37 => 'revans',
|
||||||
|
38 => 'world',
|
||||||
|
39 => 'exo',
|
||||||
|
40 => 'draconic-providence',
|
||||||
|
41 => 'celestial',
|
||||||
|
42 => 'omega-rebirth',
|
||||||
|
43 => 'collab',
|
||||||
|
44 => 'destroyer'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# GBF series_id to SummonSeries slug mapping
|
||||||
|
GBF_SUMMON_SERIES_TO_SLUG = {
|
||||||
|
1 => 'providence',
|
||||||
|
2 => 'genesis',
|
||||||
|
3 => 'magna',
|
||||||
|
4 => 'optimus',
|
||||||
|
5 => 'demi-optimus',
|
||||||
|
6 => 'archangel',
|
||||||
|
7 => 'arcarum',
|
||||||
|
8 => 'epic',
|
||||||
|
9 => 'carbuncle',
|
||||||
|
10 => 'dynamis',
|
||||||
|
12 => 'cryptid',
|
||||||
|
13 => 'six-dragons',
|
||||||
|
14 => 'summer',
|
||||||
|
15 => 'yukata',
|
||||||
|
16 => 'holiday',
|
||||||
|
17 => 'collab',
|
||||||
|
18 => 'bellum',
|
||||||
|
19 => 'crest',
|
||||||
|
20 => 'robur'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
before_action :ensure_admin_role, only: %i[weapons summons characters]
|
before_action :ensure_admin_role, only: %i[weapons summons characters]
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -92,6 +180,20 @@ module Api
|
||||||
weapon.update!(
|
weapon.update!(
|
||||||
"game_raw_#{lang}" => body.to_json
|
"game_raw_#{lang}" => body.to_json
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse series_id and assign WeaponSeries
|
||||||
|
series_id = body['series_id'] || body.dig('master', 'series_id')
|
||||||
|
if series_id
|
||||||
|
slug = GBF_WEAPON_SERIES_TO_SLUG[series_id.to_i]
|
||||||
|
if slug
|
||||||
|
series_record = WeaponSeries.find_by(slug: slug)
|
||||||
|
if series_record && weapon.weapon_series != series_record
|
||||||
|
weapon.update!(weapon_series: series_record)
|
||||||
|
Rails.logger.info "[IMPORT] Set series '#{slug}' for weapon #{weapon.granblue_id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
|
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
|
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
|
||||||
|
|
@ -121,6 +223,20 @@ module Api
|
||||||
summon.update!(
|
summon.update!(
|
||||||
"game_raw_#{lang}" => body.to_json
|
"game_raw_#{lang}" => body.to_json
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse series_id and assign SummonSeries
|
||||||
|
series_id = body['series_id'] || body.dig('master', 'series_id')
|
||||||
|
if series_id
|
||||||
|
slug = GBF_SUMMON_SERIES_TO_SLUG[series_id.to_i]
|
||||||
|
if slug
|
||||||
|
series_record = SummonSeries.find_by(slug: slug)
|
||||||
|
if series_record && summon.summon_series != series_record
|
||||||
|
summon.update!(summon_series: series_record)
|
||||||
|
Rails.logger.info "[IMPORT] Set series '#{slug}' for summon #{summon.granblue_id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
render json: { message: 'Summon gamedata updated successfully' }, status: :ok
|
render json: { message: 'Summon gamedata updated successfully' }, status: :ok
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
|
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
|
||||||
|
|
@ -154,6 +270,20 @@ module Api
|
||||||
character.update!(
|
character.update!(
|
||||||
"game_raw_#{lang}" => body.to_json
|
"game_raw_#{lang}" => body.to_json
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse series_id and create CharacterSeriesMembership
|
||||||
|
series_id = body['series_id'] || body.dig('master', 'series_id')
|
||||||
|
if series_id
|
||||||
|
slug = GBF_SERIES_TO_SLUG[series_id.to_i]
|
||||||
|
if slug
|
||||||
|
series_record = CharacterSeries.find_by(slug: slug)
|
||||||
|
if series_record && !character.character_series_records.include?(series_record)
|
||||||
|
character.character_series_memberships.create!(character_series: series_record)
|
||||||
|
Rails.logger.info "[IMPORT] Added series '#{slug}' to character #{character.granblue_id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
render json: { message: 'Character gamedata updated successfully' }, status: :ok
|
render json: { message: 'Character gamedata updated successfully' }, status: :ok
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"
|
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,87 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class JobSkillsController < Api::V1::ApiController
|
class JobSkillsController < Api::V1::ApiController
|
||||||
|
before_action :doorkeeper_authorize!, only: %i[create update destroy download_image]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update destroy download_image]
|
||||||
|
|
||||||
def all
|
def all
|
||||||
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
|
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns skills that belong to a specific job
|
||||||
def job
|
def job
|
||||||
|
job = Job.find_by(granblue_id: params[:id])
|
||||||
|
return render_not_found_response('job') unless job
|
||||||
|
|
||||||
|
@skills = JobSkill.includes(:job)
|
||||||
|
.where(job_id: job.id)
|
||||||
|
.order(:order)
|
||||||
|
render json: JobSkillBlueprint.render(@skills)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns EMP skills from other jobs (for party skill selection)
|
||||||
|
def emp
|
||||||
@skills = JobSkill.includes(:job)
|
@skills = JobSkill.includes(:job)
|
||||||
.where.not(job_id: params[:id])
|
.where.not(job_id: params[:id])
|
||||||
.where(emp: true)
|
.where(emp: true)
|
||||||
render json: JobSkillBlueprint.render(@skills)
|
render json: JobSkillBlueprint.render(@skills)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /jobs/:job_id/skills
|
||||||
|
def create
|
||||||
|
job = Job.find_by(granblue_id: params[:job_id])
|
||||||
|
return render_not_found_response('job') unless job
|
||||||
|
|
||||||
|
skill = job.skills.build(job_skill_params)
|
||||||
|
if skill.save
|
||||||
|
render json: JobSkillBlueprint.render(skill), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(skill)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /jobs/:job_id/skills/:id
|
||||||
|
def update
|
||||||
|
skill = JobSkill.find(params[:id])
|
||||||
|
if skill.update(job_skill_params)
|
||||||
|
render json: JobSkillBlueprint.render(skill)
|
||||||
|
else
|
||||||
|
render_validation_error_response(skill)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /jobs/:job_id/skills/:id
|
||||||
|
def destroy
|
||||||
|
skill = JobSkill.find(params[:id])
|
||||||
|
skill.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /jobs/:job_id/skills/:id/download_image
|
||||||
|
def download_image
|
||||||
|
skill = JobSkill.find(params[:id])
|
||||||
|
return render json: { error: 'No image_id' }, status: :unprocessable_entity unless skill.image_id.present?
|
||||||
|
return render json: { error: 'No slug' }, status: :unprocessable_entity unless skill.slug.present?
|
||||||
|
|
||||||
|
downloader = Granblue::Downloaders::JobSkillDownloader.new(skill.image_id, slug: skill.slug, storage: :s3)
|
||||||
|
result = downloader.download
|
||||||
|
|
||||||
|
render json: { success: result[:success], filename: "#{skill.slug}.png" }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def job_skill_params
|
||||||
|
params.permit(:name_en, :name_jp, :slug, :color, :main, :base, :sub, :emp, :order,
|
||||||
|
:image_id, :action_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[JOB_SKILLS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class JobsController < Api::V1::ApiController
|
class JobsController < Api::V1::ApiController
|
||||||
before_action :set, only: %w[update_job update_job_skills destroy_job_skill]
|
before_action :set_party, only: %w[update_job update_job_skills destroy_job_skill]
|
||||||
before_action :authorize, only: %w[update_job update_job_skills destroy_job_skill]
|
before_action :authorize_party, only: %w[update_job update_job_skills destroy_job_skill]
|
||||||
|
before_action :set_job, only: %w[update]
|
||||||
|
before_action :ensure_editor_role, only: %w[update]
|
||||||
|
|
||||||
def all
|
def all
|
||||||
render json: JobBlueprint.render(Job.all)
|
render json: JobBlueprint.render(Job.all)
|
||||||
|
|
@ -14,6 +16,16 @@ module Api
|
||||||
render json: JobBlueprint.render(Job.find_by(granblue_id: params[:id]))
|
render json: JobBlueprint.render(Job.find_by(granblue_id: params[:id]))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /jobs/:id
|
||||||
|
# Updates an existing job record
|
||||||
|
def update
|
||||||
|
if @job.update(job_update_params)
|
||||||
|
render json: JobBlueprint.render(@job)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@job)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def update_job
|
def update_job
|
||||||
if job_params[:job_id] != -1
|
if job_params[:job_id] != -1
|
||||||
# Extract job and find its main skills
|
# Extract job and find its main skills
|
||||||
|
|
@ -51,7 +63,7 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_job_skills
|
def update_job_skills
|
||||||
throw NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
|
raise Api::V1::NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
|
||||||
|
|
||||||
# Determine which incoming keys contain new skills
|
# Determine which incoming keys contain new skills
|
||||||
skill_keys = %w[skill1_id skill2_id skill3_id]
|
skill_keys = %w[skill1_id skill2_id skill3_id]
|
||||||
|
|
@ -59,47 +71,47 @@ module Api
|
||||||
|
|
||||||
# If there are new skills, merge them with the existing skills
|
# If there are new skills, merge them with the existing skills
|
||||||
unless new_skill_keys.empty?
|
unless new_skill_keys.empty?
|
||||||
|
# Load skills ONCE upfront to avoid N+1 queries
|
||||||
|
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
|
||||||
|
new_skills_loaded = JobSkill.where(id: new_skill_ids).index_by(&:id)
|
||||||
|
|
||||||
|
# Validate all skills exist and are compatible
|
||||||
|
new_skill_ids.each do |id|
|
||||||
|
skill = new_skills_loaded[id]
|
||||||
|
raise ActiveRecord::RecordNotFound.new("Couldn't find JobSkill") unless skill
|
||||||
|
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job, skill)
|
||||||
|
end
|
||||||
|
|
||||||
existing_skills = {
|
existing_skills = {
|
||||||
1 => @party.skill1,
|
1 => @party.skill1,
|
||||||
2 => @party.skill2,
|
2 => @party.skill2,
|
||||||
3 => @party.skill3
|
3 => @party.skill3
|
||||||
}
|
}
|
||||||
|
|
||||||
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
|
|
||||||
new_skill_ids.map do |id|
|
|
||||||
skill = JobSkill.find(id)
|
|
||||||
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job,
|
|
||||||
skill)
|
|
||||||
end
|
|
||||||
|
|
||||||
positions = extract_positions_from_keys(new_skill_keys)
|
positions = extract_positions_from_keys(new_skill_keys)
|
||||||
new_skills = merge_skills_with_existing_skills(existing_skills, new_skill_ids, positions)
|
# Pass loaded skills instead of IDs
|
||||||
|
merged = merge_skills_with_loaded_skills(existing_skills, new_skill_ids.map { |id| new_skills_loaded[id] }, positions)
|
||||||
|
|
||||||
new_skill_ids = new_skills.each_with_object({}) do |(index, skill), memo|
|
skill_ids_hash = merged.each_with_object({}) do |(index, skill), memo|
|
||||||
memo["skill#{index}_id"] = skill.id if skill
|
memo["skill#{index}_id"] = skill&.id
|
||||||
end
|
end
|
||||||
|
|
||||||
@party.attributes = new_skill_ids
|
@party.attributes = skill_ids_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
|
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy_job_skill
|
def destroy_job_skill
|
||||||
position = job_params[:skill_position].to_i
|
position = job_params[:skill_position].to_i
|
||||||
@party["skill#{position}_id"] = nil
|
@party["skill#{position}_id"] = nil
|
||||||
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save
|
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def merge_skills_with_existing_skills(
|
def merge_skills_with_loaded_skills(existing_skills, new_skills, positions)
|
||||||
existing_skills,
|
# new_skills is now an array of already-loaded JobSkill objects
|
||||||
new_skill_ids,
|
|
||||||
positions
|
|
||||||
)
|
|
||||||
new_skills = new_skill_ids.map { |id| JobSkill.find(id) }
|
|
||||||
|
|
||||||
new_skills.each_with_index do |skill, index|
|
new_skills.each_with_index do |skill, index|
|
||||||
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
|
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
|
||||||
end
|
end
|
||||||
|
|
@ -177,12 +189,35 @@ module Api
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorize
|
def authorize_party
|
||||||
render_unauthorized_response if @party.user != current_user || @party.edit_key != edit_key
|
render_unauthorized_response if @party.user != current_user || @party.edit_key != edit_key
|
||||||
end
|
end
|
||||||
|
|
||||||
def set
|
def set_party
|
||||||
@party = Party.where('id = ?', params[:id]).first
|
@party = Party.find_by(shortcode: params[:id])
|
||||||
|
render_not_found_response('party') unless @party
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_job
|
||||||
|
@job = Job.find_by(granblue_id: params[:id])
|
||||||
|
render_not_found_response('job') unless @job
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensures the current user has editor role (role >= 7)
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[JOBS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def job_update_params
|
||||||
|
params.permit(
|
||||||
|
:name_en, :name_jp, :granblue_id,
|
||||||
|
:proficiency1, :proficiency2, :row, :order,
|
||||||
|
:master_level, :ultimate_mastery,
|
||||||
|
:accessory, :accessory_type, :base_job_id
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def job_params
|
def job_params
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ module Api
|
||||||
# Default maximum clear time in seconds
|
# Default maximum clear time in seconds
|
||||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||||
|
|
||||||
before_action :set_from_slug, except: %w[create destroy update index favorites]
|
before_action :set_from_slug, except: %w[create destroy update index favorites grid_update]
|
||||||
before_action :set, only: %w[update destroy]
|
before_action :set, only: %w[update destroy grid_update]
|
||||||
before_action :authorize_party!, only: %w[update destroy]
|
before_action :authorize_party!, only: %w[update destroy grid_update]
|
||||||
|
|
||||||
# Primary CRUD Actions
|
# Primary CRUD Actions
|
||||||
|
|
||||||
|
|
@ -44,11 +44,9 @@ module Api
|
||||||
def create
|
def create
|
||||||
party = Party.new(party_params)
|
party = Party.new(party_params)
|
||||||
party.user = current_user if current_user
|
party.user = current_user if current_user
|
||||||
if party_params && party_params[:raid_id].present?
|
if party_params && party_params[:raid_id].present? && (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
|
||||||
party.extra = raid.group.extra
|
party.extra = raid.group.extra
|
||||||
end
|
end
|
||||||
end
|
|
||||||
if party.save
|
if party.save
|
||||||
party.schedule_preview_generation if party.ready_for_preview?
|
party.schedule_preview_generation if party.ready_for_preview?
|
||||||
render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
|
render json: PartyBlueprint.render(party, view: :created, root: :party), status: :created
|
||||||
|
|
@ -71,11 +69,9 @@ module Api
|
||||||
# Updates an existing party.
|
# Updates an existing party.
|
||||||
def update
|
def update
|
||||||
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
||||||
if party_params && party_params[:raid_id]
|
if party_params && party_params[:raid_id] && (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
|
||||||
@party.extra = raid.group.extra
|
@party.extra = raid.group.extra
|
||||||
end
|
end
|
||||||
end
|
|
||||||
if @party.save
|
if @party.save
|
||||||
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
render json: PartyBlueprint.render(@party, view: :full, root: :party)
|
||||||
else
|
else
|
||||||
|
|
@ -85,7 +81,13 @@ module Api
|
||||||
|
|
||||||
# Deletes a party.
|
# Deletes a party.
|
||||||
def destroy
|
def destroy
|
||||||
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
if @party.destroy
|
||||||
|
head :no_content
|
||||||
|
else
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::PartyDeletionFailedError.new(@party.errors.full_messages)
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extended Party Actions
|
# Extended Party Actions
|
||||||
|
|
@ -93,7 +95,8 @@ module Api
|
||||||
# Creates a remixed copy of an existing party.
|
# Creates a remixed copy of an existing party.
|
||||||
def remix
|
def remix
|
||||||
new_party = @party.amoeba_dup
|
new_party = @party.amoeba_dup
|
||||||
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
|
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party,
|
||||||
|
remix: true }
|
||||||
new_party.local_id = party_params[:local_id] if party_params
|
new_party.local_id = party_params[:local_id] if party_params
|
||||||
if new_party.save
|
if new_party.save
|
||||||
new_party.schedule_preview_generation
|
new_party.schedule_preview_generation
|
||||||
|
|
@ -103,11 +106,99 @@ module Api
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Batch updates grid items (weapons, characters, summons) atomically.
|
||||||
|
def grid_update
|
||||||
|
operations = grid_update_params[:operations]
|
||||||
|
options = grid_update_params[:options] || {}
|
||||||
|
|
||||||
|
# Validate all operations first
|
||||||
|
validation_errors = validate_grid_operations(operations)
|
||||||
|
if validation_errors.any?
|
||||||
|
return render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new("Validation failed: #{validation_errors.join(', ')}")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
operations.each do |operation|
|
||||||
|
change = apply_grid_operation(operation)
|
||||||
|
changes << change if change
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact character positions if needed
|
||||||
|
compact_party_character_positions if options[:maintain_character_sequence]
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
operations_applied: changes.count,
|
||||||
|
changes: changes
|
||||||
|
}, status: :ok
|
||||||
|
rescue StandardError => e
|
||||||
|
render_unprocessable_entity_response(
|
||||||
|
Api::V1::GranblueError.new("Grid update failed: #{e.message}")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Syncs all linked grid items from their collection sources.
|
||||||
|
#
|
||||||
|
# POST /parties/:id/sync_all
|
||||||
|
def sync_all
|
||||||
|
@party = Party.find_by(id: params[:id])
|
||||||
|
return render_not_found_response('party') unless @party
|
||||||
|
return render_unauthorized_response unless authorized_to_edit?
|
||||||
|
|
||||||
|
synced = { characters: 0, weapons: 0, summons: 0, artifacts: 0 }
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@party.characters.where.not(collection_character_id: nil).each do |gc|
|
||||||
|
gc.sync_from_collection!
|
||||||
|
synced[:characters] += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
@party.weapons.where.not(collection_weapon_id: nil).each do |gw|
|
||||||
|
gw.sync_from_collection!
|
||||||
|
synced[:weapons] += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
@party.summons.where.not(collection_summon_id: nil).each do |gs|
|
||||||
|
gs.sync_from_collection!
|
||||||
|
synced[:summons] += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
GridArtifact.joins(:grid_character)
|
||||||
|
.where(grid_characters: { party_id: @party.id })
|
||||||
|
.where.not(collection_artifact_id: nil)
|
||||||
|
.each do |ga|
|
||||||
|
ga.sync_from_collection!
|
||||||
|
synced[:artifacts] += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
|
||||||
|
synced: synced
|
||||||
|
}, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
# Lists parties based on query parameters.
|
# Lists parties based on query parameters.
|
||||||
def index
|
def index
|
||||||
query = build_filtered_query(build_common_base_query)
|
query = build_filtered_query(build_common_base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: page_size)
|
||||||
render_paginated_parties(@parties)
|
|
||||||
|
# Preload current user's favorite party IDs to avoid N+1
|
||||||
|
favorite_party_ids = current_user ? current_user.favorites.pluck(:party_id).to_set : Set.new
|
||||||
|
|
||||||
|
render json: Api::V1::PartyBlueprint.render(
|
||||||
|
@parties,
|
||||||
|
view: :preview,
|
||||||
|
root: :results,
|
||||||
|
meta: pagination_meta(@parties),
|
||||||
|
current_user: current_user,
|
||||||
|
favorite_party_ids: favorite_party_ids
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /api/v1/parties/favorites
|
# GET /api/v1/parties/favorites
|
||||||
|
|
@ -119,8 +210,19 @@ module Api
|
||||||
.where(favorites: { user_id: current_user.id })
|
.where(favorites: { user_id: current_user.id })
|
||||||
.distinct
|
.distinct
|
||||||
query = build_filtered_query(base_query)
|
query = build_filtered_query(base_query)
|
||||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
@parties = query.paginate(page: params[:page], per_page: page_size)
|
||||||
render_paginated_parties(@parties)
|
|
||||||
|
# All parties in this list are favorites, but preload for consistency
|
||||||
|
favorite_party_ids = current_user.favorites.pluck(:party_id).to_set
|
||||||
|
|
||||||
|
render json: Api::V1::PartyBlueprint.render(
|
||||||
|
@parties,
|
||||||
|
view: :preview,
|
||||||
|
root: :results,
|
||||||
|
meta: pagination_meta(@parties),
|
||||||
|
current_user: current_user,
|
||||||
|
favorite_party_ids: favorite_party_ids
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Preview Management
|
# Preview Management
|
||||||
|
|
@ -135,7 +237,8 @@ module Api
|
||||||
# Returns the current preview status of a party.
|
# Returns the current preview status of a party.
|
||||||
def preview_status
|
def preview_status
|
||||||
party = Party.find_by!(shortcode: params[:id])
|
party = Party.find_by!(shortcode: params[:id])
|
||||||
render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? }
|
render json: { state: party.preview_state, generated_at: party.preview_generated_at,
|
||||||
|
ready_for_preview: party.ready_for_preview? }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Forces regeneration of the party preview.
|
# Forces regeneration of the party preview.
|
||||||
|
|
@ -157,15 +260,17 @@ module Api
|
||||||
def set_from_slug
|
def set_from_slug
|
||||||
@party = Party.includes(
|
@party = Party.includes(
|
||||||
:user, :job, { raid: :group },
|
:user, :job, { raid: :group },
|
||||||
{ characters: %i[character awakening] },
|
{ characters: [:character, :awakening, :grid_artifact] },
|
||||||
{ weapons: {
|
{ weapons: {
|
||||||
weapon: [:awakenings],
|
weapon: [:awakenings, :weapon_series],
|
||||||
awakening: {},
|
awakening: {},
|
||||||
weapon_key1: {},
|
weapon_key1: {},
|
||||||
weapon_key2: {},
|
weapon_key2: {},
|
||||||
weapon_key3: {}
|
weapon_key3: {},
|
||||||
}
|
ax_modifier1: {},
|
||||||
},
|
ax_modifier2: {},
|
||||||
|
befoulment_modifier: {}
|
||||||
|
} },
|
||||||
{ summons: :summon },
|
{ summons: :summon },
|
||||||
:guidebook1, :guidebook2, :guidebook3,
|
:guidebook1, :guidebook2, :guidebook3,
|
||||||
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
|
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
|
||||||
|
|
@ -186,15 +291,141 @@ module Api
|
||||||
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
|
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
|
||||||
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
|
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
|
||||||
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
|
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
|
||||||
:turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id,
|
:turn_count, :chain_count, :summon_count, :video_url, :guidebook1_id, :guidebook2_id, :guidebook3_id,
|
||||||
characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
|
characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
|
||||||
:transcendence_step, :perpetuity, :awakening_id, :awakening_level,
|
:transcendence_step, :perpetuity, :awakening_id, :awakening_level,
|
||||||
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
|
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||||
earring: %i[modifier strength] }],
|
earring: %i[modifier strength] }],
|
||||||
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
|
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
|
||||||
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level]
|
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1_id ax_modifier2_id ax_strength1 ax_strength2 befoulment_modifier_id befoulment_strength exorcism_level awakening_id awakening_level]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Permits parameters for grid update operation.
|
||||||
|
def grid_update_params
|
||||||
|
params.permit(
|
||||||
|
operations: %i[type entity id source_id target_id position container],
|
||||||
|
options: %i[maintain_character_sequence validate_before_execute]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validates grid operations before executing.
|
||||||
|
def validate_grid_operations(operations)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
operations.each_with_index do |op, index|
|
||||||
|
case op[:type]
|
||||||
|
when 'move'
|
||||||
|
errors << "Operation #{index}: missing id" unless op[:id].present?
|
||||||
|
errors << "Operation #{index}: missing position" unless op[:position].present?
|
||||||
|
when 'swap'
|
||||||
|
errors << "Operation #{index}: missing source_id" unless op[:source_id].present?
|
||||||
|
errors << "Operation #{index}: missing target_id" unless op[:target_id].present?
|
||||||
|
when 'remove'
|
||||||
|
errors << "Operation #{index}: missing id" unless op[:id].present?
|
||||||
|
else
|
||||||
|
errors << "Operation #{index}: unknown operation type #{op[:type]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
unless %w[weapon character summon].include?(op[:entity])
|
||||||
|
errors << "Operation #{index}: invalid entity type #{op[:entity]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
errors
|
||||||
|
end
|
||||||
|
|
||||||
|
# Applies a single grid operation.
|
||||||
|
def apply_grid_operation(operation)
|
||||||
|
case operation[:type]
|
||||||
|
when 'move'
|
||||||
|
apply_move_operation(operation)
|
||||||
|
when 'swap'
|
||||||
|
apply_swap_operation(operation)
|
||||||
|
when 'remove'
|
||||||
|
apply_remove_operation(operation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Applies a move operation.
|
||||||
|
def apply_move_operation(operation)
|
||||||
|
model_class = grid_model_for_entity(operation[:entity])
|
||||||
|
item = model_class.find_by(id: operation[:id], party_id: @party.id)
|
||||||
|
|
||||||
|
return nil unless item
|
||||||
|
|
||||||
|
old_position = item.position
|
||||||
|
item.update!(position: operation[:position])
|
||||||
|
|
||||||
|
{
|
||||||
|
entity: operation[:entity],
|
||||||
|
id: operation[:id],
|
||||||
|
action: 'moved',
|
||||||
|
from: old_position,
|
||||||
|
to: operation[:position]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Applies a swap operation.
|
||||||
|
def apply_swap_operation(operation)
|
||||||
|
model_class = grid_model_for_entity(operation[:entity])
|
||||||
|
source = model_class.find_by(id: operation[:source_id], party_id: @party.id)
|
||||||
|
target = model_class.find_by(id: operation[:target_id], party_id: @party.id)
|
||||||
|
|
||||||
|
return nil unless source && target
|
||||||
|
|
||||||
|
source_pos = source.position
|
||||||
|
target_pos = target.position
|
||||||
|
|
||||||
|
# Use a temporary position to avoid conflicts
|
||||||
|
source.update!(position: -999)
|
||||||
|
target.update!(position: source_pos)
|
||||||
|
source.update!(position: target_pos)
|
||||||
|
|
||||||
|
{
|
||||||
|
entity: operation[:entity],
|
||||||
|
id: operation[:source_id],
|
||||||
|
action: 'swapped',
|
||||||
|
with: operation[:target_id]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Applies a remove operation.
|
||||||
|
def apply_remove_operation(operation)
|
||||||
|
model_class = grid_model_for_entity(operation[:entity])
|
||||||
|
item = model_class.find_by(id: operation[:id], party_id: @party.id)
|
||||||
|
|
||||||
|
return nil unless item
|
||||||
|
|
||||||
|
item.destroy
|
||||||
|
|
||||||
|
{
|
||||||
|
entity: operation[:entity],
|
||||||
|
id: operation[:id],
|
||||||
|
action: 'removed'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the model class for a given entity type.
|
||||||
|
def grid_model_for_entity(entity)
|
||||||
|
case entity
|
||||||
|
when 'weapon'
|
||||||
|
GridWeapon
|
||||||
|
when 'character'
|
||||||
|
GridCharacter
|
||||||
|
when 'summon'
|
||||||
|
GridSummon
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compacts character positions to maintain sequential filling.
|
||||||
|
def compact_party_character_positions
|
||||||
|
main_characters = @party.characters.where(position: 0..4).order(:position)
|
||||||
|
|
||||||
|
main_characters.each_with_index do |char, index|
|
||||||
|
char.update!(position: index) if char.position != index
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
21
app/controllers/api/v1/phantom_claims_controller.rb
Normal file
21
app/controllers/api/v1/phantom_claims_controller.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PhantomClaimsController < Api::V1::ApiController
|
||||||
|
before_action :restrict_access
|
||||||
|
|
||||||
|
# GET /pending_phantom_claims
|
||||||
|
# Returns phantom players assigned to the current user that are pending confirmation
|
||||||
|
def index
|
||||||
|
phantoms = PhantomPlayer
|
||||||
|
.not_deleted
|
||||||
|
.includes(:crew, :claimed_by)
|
||||||
|
.where(claimed_by: current_user, claim_confirmed: false)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
|
||||||
|
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_crew, root: :phantom_claims)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
154
app/controllers/api/v1/phantom_players_controller.rb
Normal file
154
app/controllers/api/v1/phantom_players_controller.rb
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PhantomPlayersController < Api::V1::ApiController
|
||||||
|
include CrewAuthorizationConcern
|
||||||
|
|
||||||
|
before_action :restrict_access
|
||||||
|
before_action :set_crew, except: %i[gw_scores]
|
||||||
|
before_action :set_crew_from_user, only: %i[gw_scores]
|
||||||
|
before_action :authorize_crew_member!, only: %i[index confirm_claim decline_claim gw_scores]
|
||||||
|
before_action :authorize_crew_officer!, only: %i[create bulk_create update destroy assign]
|
||||||
|
before_action :set_phantom, only: %i[show update destroy assign confirm_claim decline_claim]
|
||||||
|
before_action :set_phantom_for_scores, only: %i[gw_scores]
|
||||||
|
|
||||||
|
# GET /crews/:crew_id/phantom_players
|
||||||
|
def index
|
||||||
|
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
|
||||||
|
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_claimed_by, root: :phantom_players)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crews/:crew_id/phantom_players/:id
|
||||||
|
def show
|
||||||
|
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_scores, root: :phantom_player)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/phantom_players
|
||||||
|
def create
|
||||||
|
phantom = @crew.phantom_players.build(phantom_params)
|
||||||
|
|
||||||
|
if phantom.save
|
||||||
|
render json: PhantomPlayerBlueprint.render(phantom, root: :phantom_player), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(phantom)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/phantom_players/bulk_create
|
||||||
|
def bulk_create
|
||||||
|
phantoms = []
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
bulk_params[:phantom_players].each do |phantom_attrs|
|
||||||
|
phantom = @crew.phantom_players.build(phantom_attrs.permit(:name, :granblue_id, :notes, :joined_at))
|
||||||
|
phantom.save!
|
||||||
|
phantoms << phantom
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: PhantomPlayerBlueprint.render(phantoms, root: :phantom_players), status: :created
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# PUT /crews/:crew_id/phantom_players/:id
|
||||||
|
def update
|
||||||
|
if @phantom.update(phantom_params)
|
||||||
|
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@phantom)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /crews/:crew_id/phantom_players/:id
|
||||||
|
def destroy
|
||||||
|
@phantom.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/phantom_players/:id/assign
|
||||||
|
def assign
|
||||||
|
user = User.find(params[:user_id])
|
||||||
|
@phantom.assign_to(user)
|
||||||
|
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/phantom_players/:id/confirm_claim
|
||||||
|
def confirm_claim
|
||||||
|
@phantom.confirm_claim!(current_user)
|
||||||
|
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /crews/:crew_id/phantom_players/:id/decline_claim
|
||||||
|
def decline_claim
|
||||||
|
raise CrewErrors::NotClaimedByUserError unless @phantom.claimed_by == current_user
|
||||||
|
|
||||||
|
@phantom.unassign!
|
||||||
|
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /crew/phantom_players/:id/gw_scores
|
||||||
|
def gw_scores
|
||||||
|
# Get all crew GW events to identify gaps
|
||||||
|
all_crew_events = @crew.crew_gw_participations
|
||||||
|
.joins(:gw_event)
|
||||||
|
.order('gw_events.event_number DESC')
|
||||||
|
.pluck('gw_events.id, gw_events.event_number, gw_events.element, gw_events.start_date, gw_events.end_date')
|
||||||
|
|
||||||
|
# Get scores for this phantom
|
||||||
|
scores_by_event = GwIndividualScore
|
||||||
|
.joins(crew_gw_participation: :gw_event)
|
||||||
|
.where(phantom_player_id: @phantom.id)
|
||||||
|
.group('gw_events.id')
|
||||||
|
.pluck('gw_events.id, SUM(gw_individual_scores.score)')
|
||||||
|
.to_h
|
||||||
|
|
||||||
|
# Build event scores with gap markers
|
||||||
|
event_scores = all_crew_events.map do |event_id, event_number, element, start_date, end_date|
|
||||||
|
score = scores_by_event[event_id]
|
||||||
|
{
|
||||||
|
gw_event: { id: event_id, event_number: event_number, element: element, start_date: start_date, end_date: end_date },
|
||||||
|
total_score: score&.to_i,
|
||||||
|
in_crew: score.present?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
grand_total = event_scores.sum { |es| es[:total_score] || 0 }
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
phantom: PhantomPlayerBlueprint.render_as_hash(@phantom),
|
||||||
|
event_scores: event_scores,
|
||||||
|
grand_total: grand_total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_crew
|
||||||
|
@crew = Crew.find(params[:crew_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_crew_from_user
|
||||||
|
@crew = current_user.crew
|
||||||
|
raise CrewErrors::NotInCrewError unless @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_phantom
|
||||||
|
@phantom = @crew.phantom_players.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_phantom_for_scores
|
||||||
|
@phantom = @crew.phantom_players.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def phantom_params
|
||||||
|
params.require(:phantom_player).permit(:name, :granblue_id, :notes, :joined_at, :retired, :retired_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_params
|
||||||
|
params.permit(phantom_players: %i[name granblue_id notes joined_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
77
app/controllers/api/v1/raid_groups_controller.rb
Normal file
77
app/controllers/api/v1/raid_groups_controller.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class RaidGroupsController < Api::V1::ApiController
|
||||||
|
before_action :set_raid_group, only: %i[show update destroy]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update destroy]
|
||||||
|
|
||||||
|
# GET /raid_groups
|
||||||
|
def index
|
||||||
|
groups = RaidGroup.includes(:raids).ordered
|
||||||
|
render json: RaidGroupBlueprint.render(groups, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /raid_groups/:id
|
||||||
|
def show
|
||||||
|
if @raid_group
|
||||||
|
render json: RaidGroupBlueprint.render(@raid_group, view: :full)
|
||||||
|
else
|
||||||
|
render json: { error: 'Raid group not found' }, status: :not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /raid_groups
|
||||||
|
def create
|
||||||
|
raid_group = RaidGroup.new(raid_group_params)
|
||||||
|
|
||||||
|
if raid_group.save
|
||||||
|
render json: RaidGroupBlueprint.render(raid_group, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(raid_group)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /raid_groups/:id
|
||||||
|
def update
|
||||||
|
if @raid_group.update(raid_group_params)
|
||||||
|
render json: RaidGroupBlueprint.render(@raid_group, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@raid_group)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /raid_groups/:id
|
||||||
|
def destroy
|
||||||
|
if @raid_group.raids.exists?
|
||||||
|
render json: ErrorBlueprint.render(nil, error: {
|
||||||
|
message: 'Cannot delete group with associated raids',
|
||||||
|
code: 'has_dependencies'
|
||||||
|
}), status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
@raid_group.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_raid_group
|
||||||
|
@raid_group = RaidGroup.find_by(id: params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def raid_group_params
|
||||||
|
params.require(:raid_group).permit(
|
||||||
|
:name_en, :name_jp, :difficulty, :order, :section, :extra, :hl, :guidebooks, :unlimited
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[RAID_GROUPS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,17 +3,99 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class RaidsController < Api::V1::ApiController
|
class RaidsController < Api::V1::ApiController
|
||||||
def all
|
before_action :set_raid, only: %i[show update destroy]
|
||||||
render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested)
|
before_action :ensure_editor_role, only: %i[create update destroy]
|
||||||
|
|
||||||
|
# GET /raids
|
||||||
|
def index
|
||||||
|
raids = Raid.includes(:group)
|
||||||
|
raids = apply_filters(raids)
|
||||||
|
raids = raids.ordered
|
||||||
|
|
||||||
|
render json: RaidBlueprint.render(raids, view: :nested)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /raids/:id
|
||||||
def show
|
def show
|
||||||
raid = Raid.find_by(slug: params[:id])
|
if @raid
|
||||||
render json: RaidBlueprint.render(Raid.find_by(slug: params[:id]), view: :full) if raid
|
render json: RaidBlueprint.render(@raid, view: :full)
|
||||||
|
else
|
||||||
|
render json: { error: 'Raid not found' }, status: :not_found
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /raids
|
||||||
|
def create
|
||||||
|
raid = Raid.new(raid_params)
|
||||||
|
|
||||||
|
if raid.save
|
||||||
|
render json: RaidBlueprint.render(raid, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(raid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /raids/:id
|
||||||
|
def update
|
||||||
|
if @raid.update(raid_params)
|
||||||
|
render json: RaidBlueprint.render(@raid, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@raid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /raids/:id
|
||||||
|
def destroy
|
||||||
|
if Party.where(raid_id: @raid.id).exists?
|
||||||
|
render json: ErrorBlueprint.render(nil, error: {
|
||||||
|
message: 'Cannot delete raid with associated parties',
|
||||||
|
code: 'has_dependencies'
|
||||||
|
}), status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
@raid.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /raids/groups (legacy endpoint)
|
||||||
def groups
|
def groups
|
||||||
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full)
|
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).ordered, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Legacy alias for index
|
||||||
|
def all
|
||||||
|
index
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_raid
|
||||||
|
@raid = Raid.find_by(slug: params[:id]) || Raid.find_by(id: params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def raid_params
|
||||||
|
params.require(:raid).permit(:name_en, :name_jp, :level, :element, :slug, :group_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_filters(scope)
|
||||||
|
scope = scope.by_element(filter_params[:element]) if filter_params[:element].present?
|
||||||
|
scope = scope.by_group(filter_params[:group_id]) if filter_params[:group_id].present?
|
||||||
|
scope = scope.by_difficulty(filter_params[:difficulty]) if filter_params[:difficulty].present?
|
||||||
|
scope = scope.by_hl(filter_params[:hl]) if filter_params[:hl].present?
|
||||||
|
scope = scope.by_extra(filter_params[:extra]) if filter_params[:extra].present?
|
||||||
|
scope = scope.with_guidebooks if filter_params[:guidebooks] == 'true'
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_params
|
||||||
|
params.permit(:element, :group_id, :difficulty, :hl, :extra, :guidebooks)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[RAIDS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ module Api
|
||||||
def characters
|
def characters
|
||||||
filters = search_params[:filters]
|
filters = search_params[:filters]
|
||||||
locale = search_params[:locale] || 'en'
|
locale = search_params[:locale] || 'en'
|
||||||
|
exclude = search_params[:exclude]
|
||||||
conditions = {}
|
conditions = {}
|
||||||
|
|
||||||
if filters
|
if filters
|
||||||
|
|
@ -68,7 +69,7 @@ module Api
|
||||||
conditions[:proficiency2] =
|
conditions[:proficiency2] =
|
||||||
filters['proficiency2']
|
filters['proficiency2']
|
||||||
end
|
end
|
||||||
# conditions[:series] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
|
conditions[:season] = filters['season'] unless filters['season'].blank? || filters['season'].empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
characters = if search_params[:query].present? && search_params[:query].length >= 2
|
characters = if search_params[:query].present? && search_params[:query].length >= 2
|
||||||
|
|
@ -78,19 +79,34 @@ module Api
|
||||||
Character.en_search(search_params[:query]).where(conditions)
|
Character.en_search(search_params[:query]).where(conditions)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Character.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc'))
|
Character.where(conditions)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply sorting if specified, otherwise use default
|
||||||
|
if search_params[:sort].present?
|
||||||
|
characters = apply_sort(characters, search_params[:sort], search_params[:order], locale)
|
||||||
|
elsif search_params[:query].blank?
|
||||||
|
characters = characters.order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter by series (array overlap)
|
||||||
|
if filters && filters['series'].present? && !filters['series'].empty?
|
||||||
|
series_values = Array(filters['series']).map(&:to_i)
|
||||||
|
characters = characters.where('series && ARRAY[?]::integer[]', series_values)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Exclude already-owned characters (for collection modal)
|
||||||
|
if exclude.present? && exclude.any?
|
||||||
|
characters = characters.where.not(id: exclude)
|
||||||
end
|
end
|
||||||
|
|
||||||
count = characters.length
|
count = characters.length
|
||||||
paginated = characters.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
|
paginated = characters.paginate(page: search_params[:page], per_page: search_page_size)
|
||||||
|
|
||||||
render json: CharacterBlueprint.render(paginated,
|
render json: CharacterBlueprint.render(paginated,
|
||||||
|
view: :dates,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: SEARCH_PER_PAGE
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def weapons
|
def weapons
|
||||||
|
|
@ -105,7 +121,7 @@ module Api
|
||||||
conditions[:proficiency] =
|
conditions[:proficiency] =
|
||||||
filters['proficiency1']
|
filters['proficiency1']
|
||||||
end
|
end
|
||||||
conditions[:series] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
|
conditions[:weapon_series_id] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
|
||||||
conditions[:extra] = filters['extra'] unless filters['extra'].blank?
|
conditions[:extra] = filters['extra'] unless filters['extra'].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -116,19 +132,29 @@ module Api
|
||||||
Weapon.en_search(search_params[:query]).where(conditions)
|
Weapon.en_search(search_params[:query]).where(conditions)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Weapon.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
|
Weapon.where(conditions)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply sorting if specified, otherwise use default
|
||||||
|
if search_params[:sort].present?
|
||||||
|
weapons = apply_sort(weapons, search_params[:sort], search_params[:order], locale)
|
||||||
|
elsif search_params[:query].blank?
|
||||||
|
weapons = weapons.order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter by promotions (array overlap)
|
||||||
|
if filters && filters['promotions'].present? && !filters['promotions'].empty?
|
||||||
|
promotions_values = Array(filters['promotions']).map(&:to_i)
|
||||||
|
weapons = weapons.where('promotions && ARRAY[?]::integer[]', promotions_values)
|
||||||
end
|
end
|
||||||
|
|
||||||
count = weapons.length
|
count = weapons.length
|
||||||
paginated = weapons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
|
paginated = weapons.paginate(page: search_params[:page], per_page: search_page_size)
|
||||||
|
|
||||||
render json: WeaponBlueprint.render(paginated,
|
render json: WeaponBlueprint.render(paginated,
|
||||||
|
view: :dates,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: SEARCH_PER_PAGE
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def summons
|
def summons
|
||||||
|
|
@ -149,19 +175,29 @@ module Api
|
||||||
Summon.en_search(search_params[:query]).where(conditions)
|
Summon.en_search(search_params[:query]).where(conditions)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Summon.where(conditions).order(release_date: :desc).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
|
Summon.where(conditions)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply sorting if specified, otherwise use default
|
||||||
|
if search_params[:sort].present?
|
||||||
|
summons = apply_sort(summons, search_params[:sort], search_params[:order], locale)
|
||||||
|
elsif search_params[:query].blank?
|
||||||
|
summons = summons.order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter by promotions (array overlap)
|
||||||
|
if filters && filters['promotions'].present? && !filters['promotions'].empty?
|
||||||
|
promotions_values = Array(filters['promotions']).map(&:to_i)
|
||||||
|
summons = summons.where('promotions && ARRAY[?]::integer[]', promotions_values)
|
||||||
end
|
end
|
||||||
|
|
||||||
count = summons.length
|
count = summons.length
|
||||||
paginated = summons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
|
paginated = summons.paginate(page: search_params[:page], per_page: search_page_size)
|
||||||
|
|
||||||
render json: SummonBlueprint.render(paginated,
|
render json: SummonBlueprint.render(paginated,
|
||||||
|
view: :dates,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: SEARCH_PER_PAGE
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def job_skills
|
def job_skills
|
||||||
|
|
@ -241,15 +277,11 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = skills.length
|
count = skills.length
|
||||||
paginated = skills.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
|
paginated = skills.paginate(page: search_params[:page], per_page: search_page_size)
|
||||||
|
|
||||||
render json: JobSkillBlueprint.render(paginated,
|
render json: JobSkillBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: SEARCH_PER_PAGE
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def guidebooks
|
def guidebooks
|
||||||
|
|
@ -261,27 +293,39 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
count = books.length
|
count = books.length
|
||||||
paginated = books.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
|
paginated = books.paginate(page: search_params[:page], per_page: search_page_size)
|
||||||
|
|
||||||
render json: GuidebookBlueprint.render(paginated,
|
render json: GuidebookBlueprint.render(paginated,
|
||||||
root: :results,
|
root: :results,
|
||||||
meta: {
|
meta: pagination_meta(paginated).merge(count: count))
|
||||||
count: count,
|
|
||||||
total_pages: total_pages(count),
|
|
||||||
per_page: SEARCH_PER_PAGE
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def total_pages(count)
|
|
||||||
count.to_f / SEARCH_PER_PAGE > 1 ? (count.to_f / SEARCH_PER_PAGE).ceil : 1
|
|
||||||
end
|
|
||||||
|
|
||||||
# Specify whitelisted properties that can be modified.
|
# Specify whitelisted properties that can be modified.
|
||||||
def search_params
|
def search_params
|
||||||
|
return {} unless params[:search].present?
|
||||||
params.require(:search).permit!
|
params.require(:search).permit!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Apply sorting based on column name and order
|
||||||
|
def apply_sort(scope, column, order, locale)
|
||||||
|
sort_dir = order == 'desc' ? :desc : :asc
|
||||||
|
|
||||||
|
case column
|
||||||
|
when 'name'
|
||||||
|
name_col = locale == 'ja' ? :name_ja : :name_en
|
||||||
|
scope.order(name_col => sort_dir)
|
||||||
|
when 'element'
|
||||||
|
scope.order(element: sort_dir)
|
||||||
|
when 'rarity'
|
||||||
|
scope.order(rarity: sort_dir)
|
||||||
|
when 'last_updated'
|
||||||
|
scope.order(updated_at: sort_dir)
|
||||||
|
else
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
72
app/controllers/api/v1/summon_series_controller.rb
Normal file
72
app/controllers/api/v1/summon_series_controller.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class SummonSeriesController < Api::V1::ApiController
|
||||||
|
before_action :set_summon_series, only: %i[show update destroy]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update destroy]
|
||||||
|
|
||||||
|
# GET /summon_series
|
||||||
|
def index
|
||||||
|
summon_series = SummonSeries.ordered
|
||||||
|
render json: SummonSeriesBlueprint.render(summon_series)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /summon_series/:id
|
||||||
|
def show
|
||||||
|
render json: SummonSeriesBlueprint.render(@summon_series, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /summon_series
|
||||||
|
def create
|
||||||
|
summon_series = SummonSeries.new(summon_series_params)
|
||||||
|
|
||||||
|
if summon_series.save
|
||||||
|
render json: SummonSeriesBlueprint.render(summon_series, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(summon_series)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /summon_series/:id
|
||||||
|
def update
|
||||||
|
if @summon_series.update(summon_series_params)
|
||||||
|
render json: SummonSeriesBlueprint.render(@summon_series, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@summon_series)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /summon_series/:id
|
||||||
|
def destroy
|
||||||
|
if @summon_series.summons.exists?
|
||||||
|
render json: ErrorBlueprint.render(nil, error: {
|
||||||
|
message: 'Cannot delete series with associated summons',
|
||||||
|
code: 'has_dependencies'
|
||||||
|
}), status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
@summon_series.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_summon_series
|
||||||
|
# Support lookup by slug or UUID
|
||||||
|
@summon_series = SummonSeries.find_by(slug: params[:id]) || SummonSeries.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[SUMMON_SERIES] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def summon_series_params
|
||||||
|
params.require(:summon_series).permit(:name_en, :name_jp, :slug, :order)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,16 +3,227 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class SummonsController < Api::V1::ApiController
|
class SummonsController < Api::V1::ApiController
|
||||||
before_action :set
|
include IdResolvable
|
||||||
|
include BatchPreviewable
|
||||||
|
|
||||||
|
before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
|
||||||
|
|
||||||
|
# GET /summons/:id
|
||||||
def show
|
def show
|
||||||
render json: SummonBlueprint.render(@summon)
|
render json: SummonBlueprint.render(@summon, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /summons
|
||||||
|
# Creates a new summon record
|
||||||
|
def create
|
||||||
|
summon = Summon.new(summon_params)
|
||||||
|
|
||||||
|
if summon.save
|
||||||
|
render json: SummonBlueprint.render(summon, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(summon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /summons/:id
|
||||||
|
# Updates an existing summon record
|
||||||
|
def update
|
||||||
|
if @summon.update(summon_params)
|
||||||
|
render json: SummonBlueprint.render(@summon, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@summon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /summons/validate/:granblue_id
|
||||||
|
# Validates that a granblue_id has accessible images on Granblue servers
|
||||||
|
def validate
|
||||||
|
granblue_id = params[:granblue_id]
|
||||||
|
validator = SummonImageValidator.new(granblue_id)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
granblue_id: granblue_id,
|
||||||
|
exists_in_db: validator.exists_in_db?
|
||||||
|
}
|
||||||
|
|
||||||
|
if validator.valid?
|
||||||
|
render json: response_data.merge(
|
||||||
|
valid: true,
|
||||||
|
image_urls: validator.image_urls
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render json: response_data.merge(
|
||||||
|
valid: false,
|
||||||
|
error: validator.error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /summons/:id/download_image
|
||||||
|
# Synchronously downloads a single image for a summon
|
||||||
|
def download_image
|
||||||
|
size = params[:size]
|
||||||
|
transformation = params[:transformation]
|
||||||
|
force = params[:force] == true
|
||||||
|
|
||||||
|
# Validate size
|
||||||
|
valid_sizes = Granblue::Downloaders::SummonDownloader::SIZES
|
||||||
|
unless valid_sizes.include?(size)
|
||||||
|
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate transformation for summons (none, 02, 03, 04)
|
||||||
|
valid_transformations = [nil, '', '02', '03', '04']
|
||||||
|
if transformation.present? && !valid_transformations.include?(transformation)
|
||||||
|
return render json: { error: 'Invalid transformation. Must be one of: 02, 03, 04 (or empty for base)' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build variant ID - summons don't have suffix for base
|
||||||
|
variant_id = transformation.present? ? "#{@summon.granblue_id}_#{transformation}" : @summon.granblue_id
|
||||||
|
|
||||||
|
begin
|
||||||
|
downloader = Granblue::Downloaders::SummonDownloader.new(
|
||||||
|
@summon.granblue_id,
|
||||||
|
storage: :s3,
|
||||||
|
force: force,
|
||||||
|
verbose: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the download_variant method directly for a single variant/size
|
||||||
|
downloader.send(:download_variant, variant_id, size)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
summon_id: @summon.id,
|
||||||
|
granblue_id: @summon.granblue_id,
|
||||||
|
size: size,
|
||||||
|
transformation: transformation,
|
||||||
|
message: 'Image downloaded successfully'
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[SUMMONS] Image download error for #{@summon.id}: #{e.message}"
|
||||||
|
render json: { success: false, error: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /summons/:id/download_images
|
||||||
|
# Triggers async image download for a summon
|
||||||
|
def download_images
|
||||||
|
# Queue the download job
|
||||||
|
DownloadSummonImagesJob.perform_later(
|
||||||
|
@summon.id,
|
||||||
|
force: params.dig(:options, :force) == true,
|
||||||
|
size: params.dig(:options, :size) || 'all'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set initial status
|
||||||
|
DownloadSummonImagesJob.update_status(
|
||||||
|
@summon.id,
|
||||||
|
'queued',
|
||||||
|
progress: 0,
|
||||||
|
images_downloaded: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: 'queued',
|
||||||
|
summon_id: @summon.id,
|
||||||
|
granblue_id: @summon.granblue_id,
|
||||||
|
message: 'Image download job has been queued'
|
||||||
|
}, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /summons/:id/download_status
|
||||||
|
# Returns the status of an image download job
|
||||||
|
def download_status
|
||||||
|
status = DownloadSummonImagesJob.status(@summon.id)
|
||||||
|
|
||||||
|
render json: status.merge(
|
||||||
|
summon_id: @summon.id,
|
||||||
|
granblue_id: @summon.granblue_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /summons/:id/raw
|
||||||
|
# Returns raw wiki and game data for database viewing
|
||||||
|
def raw
|
||||||
|
render json: SummonBlueprint.render(@summon, view: :raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /summons/batch_preview
|
||||||
|
# Fetches wiki data and suggestions for multiple wiki page names
|
||||||
|
def batch_preview
|
||||||
|
wiki_pages = params[:wiki_pages]
|
||||||
|
wiki_data = params[:wiki_data] || {}
|
||||||
|
|
||||||
|
unless wiki_pages.is_a?(Array) && wiki_pages.any?
|
||||||
|
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Limit to 10 pages
|
||||||
|
wiki_pages = wiki_pages.first(10)
|
||||||
|
|
||||||
|
results = wiki_pages.map do |wiki_page|
|
||||||
|
process_wiki_preview(wiki_page, :summon, wiki_raw: wiki_data[wiki_page])
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { results: results }
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /summons/:id/fetch_wiki
|
||||||
|
# Fetches and stores wiki data for this summon
|
||||||
|
def fetch_wiki
|
||||||
|
unless @summon.wiki_en.present?
|
||||||
|
return render json: { error: 'No wiki page configured for this summon' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
wiki_text = Granblue::Parsers::Wiki.new.fetch(@summon.wiki_en)
|
||||||
|
|
||||||
|
# Handle redirects
|
||||||
|
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
|
||||||
|
if redirect_match
|
||||||
|
redirect_target = redirect_match[1]
|
||||||
|
@summon.update!(wiki_en: redirect_target)
|
||||||
|
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
|
||||||
|
end
|
||||||
|
|
||||||
|
@summon.update!(wiki_raw: wiki_text)
|
||||||
|
render json: SummonBlueprint.render(@summon, view: :raw)
|
||||||
|
rescue Granblue::WikiError => e
|
||||||
|
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[SUMMONS] Wiki fetch error for #{@summon.id}: #{e.message}"
|
||||||
|
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set
|
def set
|
||||||
@summon = Summon.where(granblue_id: params[:id]).first
|
@summon = find_by_any_id(Summon, params[:id])
|
||||||
|
render_not_found_response('summon') unless @summon
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensures the current user has editor role (role >= 7)
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[SUMMONS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def summon_params
|
||||||
|
params.require(:summon).permit(
|
||||||
|
:granblue_id, :name_en, :name_jp, :summon_id, :rarity, :element, :series,
|
||||||
|
:flb, :ulb, :transcendence, :subaura, :limit,
|
||||||
|
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb, :max_hp_xlb,
|
||||||
|
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb, :max_atk_xlb,
|
||||||
|
:max_level,
|
||||||
|
:release_date, :flb_date, :ulb_date, :transcendence_date,
|
||||||
|
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
|
||||||
|
nicknames_en: [], nicknames_jp: [], promotions: []
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ module Api
|
||||||
class UsersController < Api::V1::ApiController
|
class UsersController < Api::V1::ApiController
|
||||||
class ForbiddenError < StandardError; end
|
class ForbiddenError < StandardError; end
|
||||||
|
|
||||||
before_action :set, except: %w[create check_email check_username]
|
before_action :set, except: %w[create check_email check_username me]
|
||||||
before_action :set_by_id, only: %w[update]
|
before_action :set_by_id, only: %w[update]
|
||||||
|
before_action :doorkeeper_authorize!, only: %w[me]
|
||||||
|
|
||||||
MAX_CHARACTERS = 5
|
MAX_CHARACTERS = 5
|
||||||
MAX_SUMMONS = 8
|
MAX_SUMMONS = 8
|
||||||
|
|
@ -51,6 +52,12 @@ module Api
|
||||||
render json: UserBlueprint.render(@user, view: :minimal)
|
render json: UserBlueprint.render(@user, view: :minimal)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /users/me - returns current user's settings including email
|
||||||
|
# This endpoint is ONLY for authenticated users viewing their own settings
|
||||||
|
def me
|
||||||
|
render json: UserBlueprint.render(current_user, view: :settings)
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
render_not_found_response('user')
|
render_not_found_response('user')
|
||||||
|
|
@ -79,13 +86,14 @@ module Api
|
||||||
current_user: current_user,
|
current_user: current_user,
|
||||||
options: { skip_privacy: skip_privacy }
|
options: { skip_privacy: skip_privacy }
|
||||||
).build
|
).build
|
||||||
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE)
|
current_page_size = page_size
|
||||||
|
parties = query.paginate(page: params[:page], per_page: current_page_size)
|
||||||
count = query.count
|
count = query.count
|
||||||
render json: UserBlueprint.render(@user,
|
render json: UserBlueprint.render(@user,
|
||||||
view: :profile,
|
view: :profile,
|
||||||
root: 'profile',
|
root: 'profile',
|
||||||
parties: parties,
|
parties: parties,
|
||||||
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
|
meta: { count: count, total_pages: (count.to_f / current_page_size).ceil, per_page: current_page_size },
|
||||||
current_user: current_user
|
current_user: current_user
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -226,13 +234,18 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_by_id
|
def set_by_id
|
||||||
|
if params[:id] == 'me'
|
||||||
|
@user = current_user
|
||||||
|
else
|
||||||
@user = User.find_by('id = ?', params[:id])
|
@user = User.find_by('id = ?', params[:id])
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(
|
params.require(:user).permit(
|
||||||
:username, :email, :password, :password_confirmation,
|
:username, :email, :password, :password_confirmation,
|
||||||
:granblue_id, :picture, :element, :language, :gender, :private, :theme
|
:granblue_id, :picture, :element, :language, :gender, :private, :theme, :show_gamertag,
|
||||||
|
:show_granblue_id, :collection_privacy
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,20 @@ module Api
|
||||||
module V1
|
module V1
|
||||||
class WeaponKeysController < Api::V1::ApiController
|
class WeaponKeysController < Api::V1::ApiController
|
||||||
def all
|
def all
|
||||||
conditions = {}.tap do |hash|
|
weapon_keys = WeaponKey.all
|
||||||
hash[:series] = request.params['series'].to_i unless request.params['series'].blank?
|
|
||||||
hash[:slot] = request.params['slot'].to_i unless request.params['slot'].blank?
|
# Filter by series - support both new slug-based and legacy integer-based filtering
|
||||||
hash[:group] = request.params['group'].to_i unless request.params['group'].blank?
|
if request.params['series_slug'].present?
|
||||||
|
series = WeaponSeries.find_by(slug: request.params['series_slug'])
|
||||||
|
weapon_keys = weapon_keys.joins(:weapon_series).where(weapon_series: { id: series.id }) if series
|
||||||
|
elsif request.params['series'].present?
|
||||||
|
# Legacy integer support (will be deprecated)
|
||||||
|
weapon_keys = weapon_keys.where('? = ANY(series)', request.params['series'].to_i)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Build the query based on the conditions
|
# Filter by slot and group
|
||||||
weapon_keys = WeaponKey.all
|
weapon_keys = weapon_keys.where(slot: request.params['slot'].to_i) if request.params['slot'].present?
|
||||||
weapon_keys = weapon_keys.where('? = ANY(series)', conditions[:series]) if conditions.key?(:series)
|
weapon_keys = weapon_keys.where(group: request.params['group'].to_i) if request.params['group'].present?
|
||||||
weapon_keys = weapon_keys.where(slot: conditions[:slot]) if conditions.key?(:slot)
|
|
||||||
weapon_keys = weapon_keys.where(group: conditions[:group]) if conditions.key?(:group)
|
|
||||||
|
|
||||||
render json: WeaponKeyBlueprint.render(weapon_keys)
|
render json: WeaponKeyBlueprint.render(weapon_keys)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
76
app/controllers/api/v1/weapon_series_controller.rb
Normal file
76
app/controllers/api/v1/weapon_series_controller.rb
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class WeaponSeriesController < Api::V1::ApiController
|
||||||
|
before_action :set_weapon_series, only: %i[show update destroy]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update destroy]
|
||||||
|
|
||||||
|
# GET /weapon_series
|
||||||
|
def index
|
||||||
|
weapon_series = WeaponSeries.ordered
|
||||||
|
render json: WeaponSeriesBlueprint.render(weapon_series)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /weapon_series/:id
|
||||||
|
def show
|
||||||
|
render json: WeaponSeriesBlueprint.render(@weapon_series, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /weapon_series
|
||||||
|
def create
|
||||||
|
weapon_series = WeaponSeries.new(weapon_series_params)
|
||||||
|
|
||||||
|
if weapon_series.save
|
||||||
|
render json: WeaponSeriesBlueprint.render(weapon_series, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(weapon_series)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /weapon_series/:id
|
||||||
|
def update
|
||||||
|
if @weapon_series.update(weapon_series_params)
|
||||||
|
render json: WeaponSeriesBlueprint.render(@weapon_series, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@weapon_series)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /weapon_series/:id
|
||||||
|
def destroy
|
||||||
|
if @weapon_series.weapons.exists?
|
||||||
|
render json: ErrorBlueprint.render(nil, error: {
|
||||||
|
message: 'Cannot delete series with associated weapons',
|
||||||
|
code: 'has_dependencies'
|
||||||
|
}), status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
@weapon_series.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_weapon_series
|
||||||
|
# Support lookup by slug or UUID
|
||||||
|
@weapon_series = WeaponSeries.find_by(slug: params[:id]) || WeaponSeries.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[WEAPON_SERIES] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def weapon_series_params
|
||||||
|
params.require(:weapon_series).permit(
|
||||||
|
:name_en, :name_jp, :slug, :order,
|
||||||
|
:extra, :element_changeable, :has_weapon_keys,
|
||||||
|
:has_awakening, :augment_type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
21
app/controllers/api/v1/weapon_stat_modifiers_controller.rb
Normal file
21
app/controllers/api/v1/weapon_stat_modifiers_controller.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class WeaponStatModifiersController < Api::V1::ApiController
|
||||||
|
# GET /weapon_stat_modifiers
|
||||||
|
def index
|
||||||
|
@modifiers = WeaponStatModifier.all
|
||||||
|
@modifiers = @modifiers.where(category: params[:category]) if params[:category].present?
|
||||||
|
|
||||||
|
render json: WeaponStatModifierBlueprint.render(@modifiers, root: :weapon_stat_modifiers)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /weapon_stat_modifiers/:id
|
||||||
|
def show
|
||||||
|
@modifier = WeaponStatModifier.find(params[:id])
|
||||||
|
render json: WeaponStatModifierBlueprint.render(@modifier)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,16 +3,228 @@
|
||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class WeaponsController < Api::V1::ApiController
|
class WeaponsController < Api::V1::ApiController
|
||||||
before_action :set
|
include IdResolvable
|
||||||
|
include BatchPreviewable
|
||||||
|
|
||||||
|
before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki]
|
||||||
|
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
|
||||||
|
|
||||||
|
# GET /weapons/:id
|
||||||
def show
|
def show
|
||||||
render json: WeaponBlueprint.render(@weapon)
|
render json: WeaponBlueprint.render(@weapon, view: :full)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /weapons
|
||||||
|
# Creates a new weapon record
|
||||||
|
def create
|
||||||
|
weapon = Weapon.new(weapon_params)
|
||||||
|
|
||||||
|
if weapon.save
|
||||||
|
render json: WeaponBlueprint.render(weapon, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(weapon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /weapons/:id
|
||||||
|
# Updates an existing weapon record
|
||||||
|
def update
|
||||||
|
if @weapon.update(weapon_params)
|
||||||
|
render json: WeaponBlueprint.render(@weapon, view: :full)
|
||||||
|
else
|
||||||
|
render_validation_error_response(@weapon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /weapons/validate/:granblue_id
|
||||||
|
# Validates that a granblue_id has accessible images on Granblue servers
|
||||||
|
def validate
|
||||||
|
granblue_id = params[:granblue_id]
|
||||||
|
validator = WeaponImageValidator.new(granblue_id)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
granblue_id: granblue_id,
|
||||||
|
exists_in_db: validator.exists_in_db?
|
||||||
|
}
|
||||||
|
|
||||||
|
if validator.valid?
|
||||||
|
render json: response_data.merge(
|
||||||
|
valid: true,
|
||||||
|
image_urls: validator.image_urls
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render json: response_data.merge(
|
||||||
|
valid: false,
|
||||||
|
error: validator.error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /weapons/:id/download_image
|
||||||
|
# Synchronously downloads a single image for a weapon
|
||||||
|
def download_image
|
||||||
|
size = params[:size]
|
||||||
|
transformation = params[:transformation]
|
||||||
|
force = params[:force] == true
|
||||||
|
|
||||||
|
# Validate size
|
||||||
|
valid_sizes = Granblue::Downloaders::WeaponDownloader::SIZES
|
||||||
|
unless valid_sizes.include?(size)
|
||||||
|
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate transformation for weapons (none, 02, 03)
|
||||||
|
valid_transformations = [nil, '', '02', '03']
|
||||||
|
if transformation.present? && !valid_transformations.include?(transformation)
|
||||||
|
return render json: { error: 'Invalid transformation. Must be one of: 02, 03 (or empty for base)' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build variant ID - weapons don't have suffix for base
|
||||||
|
variant_id = transformation.present? ? "#{@weapon.granblue_id}_#{transformation}" : @weapon.granblue_id
|
||||||
|
|
||||||
|
begin
|
||||||
|
downloader = Granblue::Downloaders::WeaponDownloader.new(
|
||||||
|
@weapon.granblue_id,
|
||||||
|
storage: :s3,
|
||||||
|
force: force,
|
||||||
|
verbose: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the download_variant method directly for a single variant/size
|
||||||
|
downloader.send(:download_variant, variant_id, size)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
weapon_id: @weapon.id,
|
||||||
|
granblue_id: @weapon.granblue_id,
|
||||||
|
size: size,
|
||||||
|
transformation: transformation,
|
||||||
|
message: 'Image downloaded successfully'
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WEAPONS] Image download error for #{@weapon.id}: #{e.message}"
|
||||||
|
render json: { success: false, error: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /weapons/:id/download_images
|
||||||
|
# Triggers async image download for a weapon
|
||||||
|
def download_images
|
||||||
|
# Queue the download job
|
||||||
|
DownloadWeaponImagesJob.perform_later(
|
||||||
|
@weapon.id,
|
||||||
|
force: params.dig(:options, :force) == true,
|
||||||
|
size: params.dig(:options, :size) || 'all'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set initial status
|
||||||
|
DownloadWeaponImagesJob.update_status(
|
||||||
|
@weapon.id,
|
||||||
|
'queued',
|
||||||
|
progress: 0,
|
||||||
|
images_downloaded: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: 'queued',
|
||||||
|
weapon_id: @weapon.id,
|
||||||
|
granblue_id: @weapon.granblue_id,
|
||||||
|
message: 'Image download job has been queued'
|
||||||
|
}, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /weapons/:id/download_status
|
||||||
|
# Returns the status of an image download job
|
||||||
|
def download_status
|
||||||
|
status = DownloadWeaponImagesJob.status(@weapon.id)
|
||||||
|
|
||||||
|
render json: status.merge(
|
||||||
|
weapon_id: @weapon.id,
|
||||||
|
granblue_id: @weapon.granblue_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /weapons/:id/raw
|
||||||
|
# Returns raw wiki and game data for database viewing
|
||||||
|
def raw
|
||||||
|
render json: WeaponBlueprint.render(@weapon, view: :raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /weapons/batch_preview
|
||||||
|
# Fetches wiki data and suggestions for multiple wiki page names
|
||||||
|
def batch_preview
|
||||||
|
wiki_pages = params[:wiki_pages]
|
||||||
|
wiki_data = params[:wiki_data] || {}
|
||||||
|
|
||||||
|
unless wiki_pages.is_a?(Array) && wiki_pages.any?
|
||||||
|
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# Limit to 10 pages
|
||||||
|
wiki_pages = wiki_pages.first(10)
|
||||||
|
|
||||||
|
results = wiki_pages.map do |wiki_page|
|
||||||
|
process_wiki_preview(wiki_page, :weapon, wiki_raw: wiki_data[wiki_page])
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { results: results }
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /weapons/:id/fetch_wiki
|
||||||
|
# Fetches and stores wiki data for this weapon
|
||||||
|
def fetch_wiki
|
||||||
|
unless @weapon.wiki_en.present?
|
||||||
|
return render json: { error: 'No wiki page configured for this weapon' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
wiki_text = Granblue::Parsers::Wiki.new.fetch(@weapon.wiki_en)
|
||||||
|
|
||||||
|
# Handle redirects
|
||||||
|
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
|
||||||
|
if redirect_match
|
||||||
|
redirect_target = redirect_match[1]
|
||||||
|
@weapon.update!(wiki_en: redirect_target)
|
||||||
|
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
|
||||||
|
end
|
||||||
|
|
||||||
|
@weapon.update!(wiki_raw: wiki_text)
|
||||||
|
render json: WeaponBlueprint.render(@weapon, view: :raw)
|
||||||
|
rescue Granblue::WikiError => e
|
||||||
|
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WEAPONS] Wiki fetch error for #{@weapon.id}: #{e.message}"
|
||||||
|
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set
|
def set
|
||||||
@weapon = Weapon.where(granblue_id: params[:id]).first
|
@weapon = find_by_any_id(Weapon, params[:id])
|
||||||
|
render_not_found_response('weapon') unless @weapon
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensures the current user has editor role (role >= 7)
|
||||||
|
def ensure_editor_role
|
||||||
|
return if current_user&.role && current_user.role >= 7
|
||||||
|
|
||||||
|
Rails.logger.warn "[WEAPONS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def weapon_params
|
||||||
|
params.require(:weapon).permit(
|
||||||
|
:granblue_id, :name_en, :name_jp, :rarity, :element, :proficiency, :series, :new_series,
|
||||||
|
:flb, :ulb, :transcendence, :extra, :extra_prerequisite, :limit, :ax, :gacha,
|
||||||
|
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
|
||||||
|
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
|
||||||
|
:max_level, :max_skill_level, :max_awakening_level,
|
||||||
|
:release_date, :flb_date, :ulb_date, :transcendence_date,
|
||||||
|
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
|
||||||
|
:recruits, :forged_from, :forge_chain_id, :forge_order,
|
||||||
|
nicknames_en: [], nicknames_jp: [], promotions: []
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
98
app/controllers/concerns/batch_previewable.rb
Normal file
98
app/controllers/concerns/batch_previewable.rb
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Provides batch wiki preview functionality for entity controllers
|
||||||
|
module BatchPreviewable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Process a single wiki page and return preview data
|
||||||
|
# @param wiki_page [String] The wiki page name to fetch
|
||||||
|
# @param entity_type [Symbol] The type of entity (:character, :weapon, :summon)
|
||||||
|
# @param wiki_raw [String, nil] Pre-fetched wiki text (from client-side fetch)
|
||||||
|
# @return [Hash] Preview data including status, suggestions, and errors
|
||||||
|
def process_wiki_preview(wiki_page, entity_type, wiki_raw: nil)
|
||||||
|
result = {
|
||||||
|
wiki_page: wiki_page,
|
||||||
|
status: 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Use provided wiki_raw or fetch from wiki
|
||||||
|
wiki_text = if wiki_raw.present?
|
||||||
|
wiki_raw
|
||||||
|
else
|
||||||
|
wiki = Granblue::Parsers::Wiki.new
|
||||||
|
wiki.fetch(wiki_page)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle redirects (only if we fetched server-side)
|
||||||
|
if wiki_raw.blank?
|
||||||
|
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
|
||||||
|
if redirect_match
|
||||||
|
redirect_target = redirect_match[1]
|
||||||
|
result[:redirected_from] = wiki_page
|
||||||
|
result[:wiki_page] = redirect_target
|
||||||
|
wiki_text = wiki.fetch(redirect_target)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result[:wiki_raw] = wiki_text
|
||||||
|
|
||||||
|
# Parse suggestions based on entity type
|
||||||
|
suggestions = case entity_type
|
||||||
|
when :character
|
||||||
|
Granblue::Parsers::SuggestionParser.parse_character(wiki_text)
|
||||||
|
when :weapon
|
||||||
|
Granblue::Parsers::SuggestionParser.parse_weapon(wiki_text)
|
||||||
|
when :summon
|
||||||
|
Granblue::Parsers::SuggestionParser.parse_summon(wiki_text)
|
||||||
|
end
|
||||||
|
|
||||||
|
result[:granblue_id] = suggestions[:granblue_id] if suggestions[:granblue_id].present?
|
||||||
|
result[:suggestions] = suggestions
|
||||||
|
|
||||||
|
# Queue image download if we have a granblue_id
|
||||||
|
if suggestions[:granblue_id].present?
|
||||||
|
result[:image_status] = queue_image_download(suggestions[:granblue_id], entity_type)
|
||||||
|
else
|
||||||
|
result[:image_status] = 'no_id'
|
||||||
|
end
|
||||||
|
rescue Granblue::WikiError => e
|
||||||
|
result[:status] = 'error'
|
||||||
|
result[:error] = "Wiki page not found: #{e.message}"
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[BATCH_PREVIEW] Error processing #{wiki_page}: #{e.message}"
|
||||||
|
result[:status] = 'error'
|
||||||
|
result[:error] = "Failed to process wiki page: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
# Queue an image download job for the entity
|
||||||
|
# @param granblue_id [String] The granblue ID to download images for
|
||||||
|
# @param entity_type [Symbol] The type of entity
|
||||||
|
# @return [String] Status of the image download ('queued', 'skipped', 'error')
|
||||||
|
def queue_image_download(granblue_id, entity_type)
|
||||||
|
# Check if entity already exists in database
|
||||||
|
model_class = case entity_type
|
||||||
|
when :character then Character
|
||||||
|
when :weapon then Weapon
|
||||||
|
when :summon then Summon
|
||||||
|
end
|
||||||
|
|
||||||
|
existing = model_class.find_by(granblue_id: granblue_id)
|
||||||
|
if existing
|
||||||
|
# Entity exists, skip download (images likely already exist)
|
||||||
|
return 'exists'
|
||||||
|
end
|
||||||
|
|
||||||
|
# For now, we don't queue the download since the entity doesn't exist yet
|
||||||
|
# The image download will happen after the entity is created
|
||||||
|
'pending'
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[BATCH_PREVIEW] Error queueing image download: #{e.message}"
|
||||||
|
'error'
|
||||||
|
end
|
||||||
|
end
|
||||||
20
app/controllers/concerns/crew_authorization_concern.rb
Normal file
20
app/controllers/concerns/crew_authorization_concern.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module CrewAuthorizationConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Checks whether the current user is a member of the crew
|
||||||
|
def authorize_crew_member!
|
||||||
|
render_unauthorized_response unless current_user&.crew == @crew
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks whether the current user is an officer (captain or vice captain) of the crew
|
||||||
|
def authorize_crew_officer!
|
||||||
|
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_officer?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks whether the current user is the captain of the crew
|
||||||
|
def authorize_crew_captain!
|
||||||
|
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_captain?
|
||||||
|
end
|
||||||
|
end
|
||||||
23
app/controllers/concerns/id_resolvable.rb
Normal file
23
app/controllers/concerns/id_resolvable.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module IdResolvable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def uuid_format?(id)
|
||||||
|
id.to_s.match?(UUID_REGEX)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_by_any_id(model_class, id)
|
||||||
|
return nil if id.blank?
|
||||||
|
|
||||||
|
if uuid_format?(id)
|
||||||
|
model_class.find_by(id: id)
|
||||||
|
else
|
||||||
|
model_class.find_by(granblue_id: id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,6 +5,8 @@ module PartyAuthorizationConcern
|
||||||
|
|
||||||
# Checks whether the current user (or provided edit key) is authorized to modify @party.
|
# Checks whether the current user (or provided edit key) is authorized to modify @party.
|
||||||
def authorize_party!
|
def authorize_party!
|
||||||
|
return render_not_found_response('party') unless @party
|
||||||
|
|
||||||
if @party.user.present?
|
if @party.user.present?
|
||||||
render_unauthorized_response unless current_user.present? && @party.user == current_user
|
render_unauthorized_response unless current_user.present? && @party.user == current_user
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ module PartyQueryingConcern
|
||||||
Party.includes(
|
Party.includes(
|
||||||
{ raid: :group },
|
{ raid: :group },
|
||||||
:job,
|
:job,
|
||||||
:user,
|
{ user: { active_crew_membership: :crew } },
|
||||||
:skill0,
|
:skill0,
|
||||||
:skill1,
|
:skill1,
|
||||||
:skill2,
|
:skill2,
|
||||||
|
|
@ -18,7 +18,7 @@ module PartyQueryingConcern
|
||||||
:guidebook2,
|
:guidebook2,
|
||||||
:guidebook3,
|
:guidebook3,
|
||||||
{ characters: :character },
|
{ characters: :character },
|
||||||
{ weapons: :weapon },
|
{ weapons: { weapon: :weapon_series } },
|
||||||
{ summons: :summon }
|
{ summons: :summon }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -31,21 +31,6 @@ module PartyQueryingConcern
|
||||||
options: { apply_defaults: true }).build
|
options: { apply_defaults: true }).build
|
||||||
end
|
end
|
||||||
|
|
||||||
# Renders paginated parties using PartyBlueprint.
|
|
||||||
def render_paginated_parties(parties)
|
|
||||||
render json: Api::V1::PartyBlueprint.render(
|
|
||||||
parties,
|
|
||||||
view: :preview,
|
|
||||||
root: :results,
|
|
||||||
meta: {
|
|
||||||
count: parties.total_entries,
|
|
||||||
total_pages: parties.total_pages,
|
|
||||||
per_page: COLLECTION_PER_PAGE
|
|
||||||
},
|
|
||||||
current_user: current_user
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns a remixed party name based on the current party name and current_user language.
|
# Returns a remixed party name based on the current party name and current_user language.
|
||||||
def remixed_name(name)
|
def remixed_name(name)
|
||||||
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
|
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class WikiError < StandardError
|
|
||||||
def initialize(code: nil, page: nil, message: nil)
|
|
||||||
super
|
|
||||||
@code = code
|
|
||||||
@page = page
|
|
||||||
@message = message
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_hash
|
|
||||||
{
|
|
||||||
message: @message,
|
|
||||||
code: @code,
|
|
||||||
page: @page
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
15
app/errors/api/v1/invalid_position_error.rb
Normal file
15
app/errors/api/v1/invalid_position_error.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class InvalidPositionError < GranblueError
|
||||||
|
def code
|
||||||
|
'invalid_position'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
@data || 'Invalid position specified'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
38
app/errors/api/v1/party_deletion_failed_error.rb
Normal file
38
app/errors/api/v1/party_deletion_failed_error.rb
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PartyDeletionFailedError < StandardError
|
||||||
|
attr_reader :errors
|
||||||
|
|
||||||
|
def initialize(errors = [])
|
||||||
|
@errors = errors
|
||||||
|
super(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_status
|
||||||
|
422
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'party_deletion_failed'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
if @errors.any?
|
||||||
|
"Failed to delete party: #{@errors.join(', ')}"
|
||||||
|
else
|
||||||
|
'Failed to delete party due to an unknown error'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_hash
|
||||||
|
{
|
||||||
|
message: message,
|
||||||
|
code: code,
|
||||||
|
errors: @errors
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
15
app/errors/api/v1/position_occupied_error.rb
Normal file
15
app/errors/api/v1/position_occupied_error.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PositionOccupiedError < GranblueError
|
||||||
|
def code
|
||||||
|
'position_occupied'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
@data || 'Position is already occupied'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
48
app/errors/collection_errors.rb
Normal file
48
app/errors/collection_errors.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module CollectionErrors
|
||||||
|
# Base class for all collection-related errors
|
||||||
|
class CollectionError < StandardError
|
||||||
|
attr_reader :http_status, :code
|
||||||
|
|
||||||
|
def initialize(message = nil, http_status: :unprocessable_entity, code: nil)
|
||||||
|
super(message)
|
||||||
|
@http_status = http_status
|
||||||
|
@code = code || self.class.name.demodulize.underscore
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_hash
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
type: self.class.name.demodulize,
|
||||||
|
message: message,
|
||||||
|
code: code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raised when a collection item cannot be found
|
||||||
|
class CollectionItemNotFound < CollectionError
|
||||||
|
def initialize(item_type = 'item', item_id = nil)
|
||||||
|
message = item_id ? "Collection #{item_type} with ID #{item_id} not found" : "Collection #{item_type} not found"
|
||||||
|
super(message, http_status: :not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raised when trying to add a duplicate character to collection
|
||||||
|
class DuplicateCharacter < CollectionError
|
||||||
|
def initialize(character_id = nil)
|
||||||
|
message = character_id ? "Character #{character_id} already exists in your collection" : "Character already exists in your collection"
|
||||||
|
super(message, http_status: :conflict)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raised when trying to add a duplicate job accessory to collection
|
||||||
|
class DuplicateJobAccessory < CollectionError
|
||||||
|
def initialize(accessory_id = nil)
|
||||||
|
message = accessory_id ? "Job accessory #{accessory_id} already exists in your collection" : "Job accessory already exists in your collection"
|
||||||
|
super(message, http_status: :conflict)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
203
app/errors/crew_errors.rb
Normal file
203
app/errors/crew_errors.rb
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module CrewErrors
|
||||||
|
# Base class for all crew-related errors
|
||||||
|
class CrewError < StandardError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
self.class.name.demodulize.underscore
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_hash
|
||||||
|
{
|
||||||
|
message: message,
|
||||||
|
code: code
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class AlreadyInCrewError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'already_in_crew'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'You are already in a crew'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CaptainCannotLeaveError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'captain_cannot_leave'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Captain must transfer ownership before leaving'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CannotRemoveCaptainError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'cannot_remove_captain'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Cannot remove the captain from the crew'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ViceCaptainLimitError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'vice_captain_limit'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Crew can only have up to 3 vice captains'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class NotInCrewError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'not_in_crew'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'You are not in a crew'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class MemberNotFoundError < CrewError
|
||||||
|
def http_status
|
||||||
|
:not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'member_not_found'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Member not found in this crew'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CannotDemoteCaptainError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'cannot_demote_captain'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Cannot demote the captain'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class InvitationExpiredError < CrewError
|
||||||
|
def http_status
|
||||||
|
:gone
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'invitation_expired'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'This invitation has expired'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class InvitationNotFoundError < CrewError
|
||||||
|
def http_status
|
||||||
|
:not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'invitation_not_found'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Invitation not found'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CannotInviteSelfError < CrewError
|
||||||
|
def http_status
|
||||||
|
:unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'cannot_invite_self'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'You cannot invite yourself'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class UserAlreadyInvitedError < CrewError
|
||||||
|
def http_status
|
||||||
|
:conflict
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'user_already_invited'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'User already has a pending invitation'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class NotClaimedByUserError < CrewError
|
||||||
|
def http_status
|
||||||
|
:forbidden
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'not_claimed_by_user'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'This phantom player is not assigned to you'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class PhantomNotFoundError < CrewError
|
||||||
|
def http_status
|
||||||
|
:not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def code
|
||||||
|
'phantom_not_found'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
'Phantom player not found'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
87
app/jobs/download_artifact_images_job.rb
Normal file
87
app/jobs/download_artifact_images_job.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Background job for downloading artifact images from Granblue servers to S3.
|
||||||
|
# Stores progress in Redis for status polling.
|
||||||
|
#
|
||||||
|
# @example Enqueue a download job
|
||||||
|
# job = DownloadArtifactImagesJob.perform_later(artifact.id)
|
||||||
|
# # Poll status with: DownloadArtifactImagesJob.status(artifact.id)
|
||||||
|
class DownloadArtifactImagesJob < ApplicationJob
|
||||||
|
queue_as :downloads
|
||||||
|
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
discard_on ActiveRecord::RecordNotFound do |job, _error|
|
||||||
|
artifact_id = job.arguments.first
|
||||||
|
Rails.logger.error "[DownloadArtifactImages] Artifact #{artifact_id} not found"
|
||||||
|
update_status(artifact_id, 'failed', error: 'Artifact not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Status keys for Redis storage
|
||||||
|
REDIS_KEY_PREFIX = 'artifact_image_download'
|
||||||
|
STATUS_TTL = 1.hour.to_i
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Get the current status of a download job for an artifact
|
||||||
|
#
|
||||||
|
# @param artifact_id [String] UUID of the artifact
|
||||||
|
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
|
||||||
|
def status(artifact_id)
|
||||||
|
data = redis.get(redis_key(artifact_id))
|
||||||
|
return { status: 'not_found' } unless data
|
||||||
|
|
||||||
|
JSON.parse(data, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_key(artifact_id)
|
||||||
|
"#{REDIS_KEY_PREFIX}:#{artifact_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status(artifact_id, status, **attrs)
|
||||||
|
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
|
||||||
|
redis.setex(redis_key(artifact_id), STATUS_TTL, data.to_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(artifact_id, force: false, size: 'all')
|
||||||
|
Rails.logger.info "[DownloadArtifactImages] Starting download for artifact #{artifact_id}"
|
||||||
|
|
||||||
|
artifact = Artifact.find(artifact_id)
|
||||||
|
update_status(artifact_id, 'processing', progress: 0, images_downloaded: 0)
|
||||||
|
|
||||||
|
service = ArtifactImageDownloadService.new(
|
||||||
|
artifact,
|
||||||
|
force: force,
|
||||||
|
size: size,
|
||||||
|
storage: :s3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.download
|
||||||
|
|
||||||
|
if result.success?
|
||||||
|
Rails.logger.info "[DownloadArtifactImages] Completed for artifact #{artifact_id}"
|
||||||
|
update_status(
|
||||||
|
artifact_id,
|
||||||
|
'completed',
|
||||||
|
progress: 100,
|
||||||
|
images_downloaded: result.total,
|
||||||
|
images_total: result.total,
|
||||||
|
images: result.images
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Rails.logger.error "[DownloadArtifactImages] Failed for artifact #{artifact_id}: #{result.error}"
|
||||||
|
update_status(artifact_id, 'failed', error: result.error)
|
||||||
|
raise StandardError, result.error # Trigger retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_status(artifact_id, status, **attrs)
|
||||||
|
self.class.update_status(artifact_id, status, **attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
87
app/jobs/download_character_images_job.rb
Normal file
87
app/jobs/download_character_images_job.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Background job for downloading character images from Granblue servers to S3.
|
||||||
|
# Stores progress in Redis for status polling.
|
||||||
|
#
|
||||||
|
# @example Enqueue a download job
|
||||||
|
# job = DownloadCharacterImagesJob.perform_later(character.id)
|
||||||
|
# # Poll status with: DownloadCharacterImagesJob.status(character.id)
|
||||||
|
class DownloadCharacterImagesJob < ApplicationJob
|
||||||
|
queue_as :downloads
|
||||||
|
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
discard_on ActiveRecord::RecordNotFound do |job, _error|
|
||||||
|
character_id = job.arguments.first
|
||||||
|
Rails.logger.error "[DownloadCharacterImages] Character #{character_id} not found"
|
||||||
|
update_status(character_id, 'failed', error: 'Character not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Status keys for Redis storage
|
||||||
|
REDIS_KEY_PREFIX = 'character_image_download'
|
||||||
|
STATUS_TTL = 1.hour.to_i
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Get the current status of a download job for a character
|
||||||
|
#
|
||||||
|
# @param character_id [String] UUID of the character
|
||||||
|
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
|
||||||
|
def status(character_id)
|
||||||
|
data = redis.get(redis_key(character_id))
|
||||||
|
return { status: 'not_found' } unless data
|
||||||
|
|
||||||
|
JSON.parse(data, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_key(character_id)
|
||||||
|
"#{REDIS_KEY_PREFIX}:#{character_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status(character_id, status, **attrs)
|
||||||
|
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
|
||||||
|
redis.setex(redis_key(character_id), STATUS_TTL, data.to_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(character_id, force: false, size: 'all')
|
||||||
|
Rails.logger.info "[DownloadCharacterImages] Starting download for character #{character_id}"
|
||||||
|
|
||||||
|
character = Character.find(character_id)
|
||||||
|
update_status(character_id, 'processing', progress: 0, images_downloaded: 0)
|
||||||
|
|
||||||
|
service = CharacterImageDownloadService.new(
|
||||||
|
character,
|
||||||
|
force: force,
|
||||||
|
size: size,
|
||||||
|
storage: :s3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.download
|
||||||
|
|
||||||
|
if result.success?
|
||||||
|
Rails.logger.info "[DownloadCharacterImages] Completed for character #{character_id}"
|
||||||
|
update_status(
|
||||||
|
character_id,
|
||||||
|
'completed',
|
||||||
|
progress: 100,
|
||||||
|
images_downloaded: result.total,
|
||||||
|
images_total: result.total,
|
||||||
|
images: result.images
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Rails.logger.error "[DownloadCharacterImages] Failed for character #{character_id}: #{result.error}"
|
||||||
|
update_status(character_id, 'failed', error: result.error)
|
||||||
|
raise StandardError, result.error # Trigger retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_status(character_id, status, **attrs)
|
||||||
|
self.class.update_status(character_id, status, **attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
87
app/jobs/download_summon_images_job.rb
Normal file
87
app/jobs/download_summon_images_job.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Background job for downloading summon images from Granblue servers to S3.
|
||||||
|
# Stores progress in Redis for status polling.
|
||||||
|
#
|
||||||
|
# @example Enqueue a download job
|
||||||
|
# job = DownloadSummonImagesJob.perform_later(summon.id)
|
||||||
|
# # Poll status with: DownloadSummonImagesJob.status(summon.id)
|
||||||
|
class DownloadSummonImagesJob < ApplicationJob
|
||||||
|
queue_as :downloads
|
||||||
|
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
discard_on ActiveRecord::RecordNotFound do |job, _error|
|
||||||
|
summon_id = job.arguments.first
|
||||||
|
Rails.logger.error "[DownloadSummonImages] Summon #{summon_id} not found"
|
||||||
|
update_status(summon_id, 'failed', error: 'Summon not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Status keys for Redis storage
|
||||||
|
REDIS_KEY_PREFIX = 'summon_image_download'
|
||||||
|
STATUS_TTL = 1.hour.to_i
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Get the current status of a download job for a summon
|
||||||
|
#
|
||||||
|
# @param summon_id [String] UUID of the summon
|
||||||
|
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
|
||||||
|
def status(summon_id)
|
||||||
|
data = redis.get(redis_key(summon_id))
|
||||||
|
return { status: 'not_found' } unless data
|
||||||
|
|
||||||
|
JSON.parse(data, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_key(summon_id)
|
||||||
|
"#{REDIS_KEY_PREFIX}:#{summon_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status(summon_id, status, **attrs)
|
||||||
|
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
|
||||||
|
redis.setex(redis_key(summon_id), STATUS_TTL, data.to_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(summon_id, force: false, size: 'all')
|
||||||
|
Rails.logger.info "[DownloadSummonImages] Starting download for summon #{summon_id}"
|
||||||
|
|
||||||
|
summon = Summon.find(summon_id)
|
||||||
|
update_status(summon_id, 'processing', progress: 0, images_downloaded: 0)
|
||||||
|
|
||||||
|
service = SummonImageDownloadService.new(
|
||||||
|
summon,
|
||||||
|
force: force,
|
||||||
|
size: size,
|
||||||
|
storage: :s3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.download
|
||||||
|
|
||||||
|
if result.success?
|
||||||
|
Rails.logger.info "[DownloadSummonImages] Completed for summon #{summon_id}"
|
||||||
|
update_status(
|
||||||
|
summon_id,
|
||||||
|
'completed',
|
||||||
|
progress: 100,
|
||||||
|
images_downloaded: result.total,
|
||||||
|
images_total: result.total,
|
||||||
|
images: result.images
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Rails.logger.error "[DownloadSummonImages] Failed for summon #{summon_id}: #{result.error}"
|
||||||
|
update_status(summon_id, 'failed', error: result.error)
|
||||||
|
raise StandardError, result.error # Trigger retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_status(summon_id, status, **attrs)
|
||||||
|
self.class.update_status(summon_id, status, **attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
87
app/jobs/download_weapon_images_job.rb
Normal file
87
app/jobs/download_weapon_images_job.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Background job for downloading weapon images from Granblue servers to S3.
|
||||||
|
# Stores progress in Redis for status polling.
|
||||||
|
#
|
||||||
|
# @example Enqueue a download job
|
||||||
|
# job = DownloadWeaponImagesJob.perform_later(weapon.id)
|
||||||
|
# # Poll status with: DownloadWeaponImagesJob.status(weapon.id)
|
||||||
|
class DownloadWeaponImagesJob < ApplicationJob
|
||||||
|
queue_as :downloads
|
||||||
|
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
discard_on ActiveRecord::RecordNotFound do |job, _error|
|
||||||
|
weapon_id = job.arguments.first
|
||||||
|
Rails.logger.error "[DownloadWeaponImages] Weapon #{weapon_id} not found"
|
||||||
|
update_status(weapon_id, 'failed', error: 'Weapon not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Status keys for Redis storage
|
||||||
|
REDIS_KEY_PREFIX = 'weapon_image_download'
|
||||||
|
STATUS_TTL = 1.hour.to_i
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Get the current status of a download job for a weapon
|
||||||
|
#
|
||||||
|
# @param weapon_id [String] UUID of the weapon
|
||||||
|
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
|
||||||
|
def status(weapon_id)
|
||||||
|
data = redis.get(redis_key(weapon_id))
|
||||||
|
return { status: 'not_found' } unless data
|
||||||
|
|
||||||
|
JSON.parse(data, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_key(weapon_id)
|
||||||
|
"#{REDIS_KEY_PREFIX}:#{weapon_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status(weapon_id, status, **attrs)
|
||||||
|
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
|
||||||
|
redis.setex(redis_key(weapon_id), STATUS_TTL, data.to_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(weapon_id, force: false, size: 'all')
|
||||||
|
Rails.logger.info "[DownloadWeaponImages] Starting download for weapon #{weapon_id}"
|
||||||
|
|
||||||
|
weapon = Weapon.find(weapon_id)
|
||||||
|
update_status(weapon_id, 'processing', progress: 0, images_downloaded: 0)
|
||||||
|
|
||||||
|
service = WeaponImageDownloadService.new(
|
||||||
|
weapon,
|
||||||
|
force: force,
|
||||||
|
size: size,
|
||||||
|
storage: :s3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.download
|
||||||
|
|
||||||
|
if result.success?
|
||||||
|
Rails.logger.info "[DownloadWeaponImages] Completed for weapon #{weapon_id}"
|
||||||
|
update_status(
|
||||||
|
weapon_id,
|
||||||
|
'completed',
|
||||||
|
progress: 100,
|
||||||
|
images_downloaded: result.total,
|
||||||
|
images_total: result.total,
|
||||||
|
images: result.images
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Rails.logger.error "[DownloadWeaponImages] Failed for weapon #{weapon_id}: #{result.error}"
|
||||||
|
update_status(weapon_id, 'failed', error: result.error)
|
||||||
|
raise StandardError, result.error # Trigger retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_status(weapon_id, status, **attrs)
|
||||||
|
self.class.update_status(weapon_id, status, **attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
36
app/models/artifact.rb
Normal file
36
app/models/artifact.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Artifact < ApplicationRecord
|
||||||
|
# Enums - using GranblueEnums::PROFICIENCY values (excluding None: 0)
|
||||||
|
# Sabre: 1, Dagger: 2, Axe: 3, Spear: 4, Bow: 5, Staff: 6, Melee: 7, Harp: 8, Gun: 9, Katana: 10
|
||||||
|
enum :proficiency, {
|
||||||
|
sabre: 1,
|
||||||
|
dagger: 2,
|
||||||
|
axe: 3,
|
||||||
|
spear: 4,
|
||||||
|
bow: 5,
|
||||||
|
staff: 6,
|
||||||
|
melee: 7,
|
||||||
|
harp: 8,
|
||||||
|
gun: 9,
|
||||||
|
katana: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
enum :rarity, { standard: 0, quirk: 1 }
|
||||||
|
|
||||||
|
# Associations
|
||||||
|
has_many :collection_artifacts, dependent: :restrict_with_error
|
||||||
|
has_many :grid_artifacts, dependent: :restrict_with_error
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :granblue_id, presence: true, uniqueness: true
|
||||||
|
validates :name_en, presence: true
|
||||||
|
validates :proficiency, presence: true, if: :standard?
|
||||||
|
validates :proficiency, absence: true, if: :quirk?
|
||||||
|
validates :rarity, presence: true
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :standard_artifacts, -> { where(rarity: :standard) }
|
||||||
|
scope :quirk_artifacts, -> { where(rarity: :quirk) }
|
||||||
|
scope :by_proficiency, ->(prof) { where(proficiency: prof) }
|
||||||
|
end
|
||||||
103
app/models/artifact_skill.rb
Normal file
103
app/models/artifact_skill.rb
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ArtifactSkill < ApplicationRecord
|
||||||
|
# Enums
|
||||||
|
enum :skill_group, { group_i: 1, group_ii: 2, group_iii: 3 }
|
||||||
|
enum :polarity, { positive: 'positive', negative: 'negative' }
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :skill_group, presence: true
|
||||||
|
validates :modifier, presence: true, uniqueness: { scope: :skill_group }
|
||||||
|
validates :name_en, presence: true
|
||||||
|
validates :name_jp, presence: true
|
||||||
|
validates :polarity, presence: true
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :for_slot, ->(slot) {
|
||||||
|
case slot
|
||||||
|
when 1, 2 then group_i
|
||||||
|
when 3 then group_ii
|
||||||
|
when 4 then group_iii
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
# Class methods for caching skill lookups
|
||||||
|
class << self
|
||||||
|
def cached_skills
|
||||||
|
@cached_skills ||= all.index_by { |s| [s.skill_group, s.modifier] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def cached_by_game_name
|
||||||
|
@game_name_cache ||= begin
|
||||||
|
cache = {}
|
||||||
|
all.each do |skill|
|
||||||
|
# Use game names for matching, fall back to display names if not set
|
||||||
|
en_key = skill.game_name_en.presence || skill.name_en
|
||||||
|
jp_key = skill.game_name_jp.presence || skill.name_jp
|
||||||
|
cache[en_key] = skill
|
||||||
|
cache[jp_key] = skill
|
||||||
|
end
|
||||||
|
cache
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_skill(group, modifier)
|
||||||
|
# Convert group number to enum key
|
||||||
|
group_key = case group
|
||||||
|
when 1 then 'group_i'
|
||||||
|
when 2 then 'group_ii'
|
||||||
|
when 3 then 'group_iii'
|
||||||
|
else group.to_s
|
||||||
|
end
|
||||||
|
cached_skills[[group_key, modifier]]
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_by_game_name(name)
|
||||||
|
cached_by_game_name[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_cache!
|
||||||
|
@cached_skills = nil
|
||||||
|
@game_name_cache = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Calculate the current value of a skill given base strength and skill level
|
||||||
|
# @param base_strength [Numeric] The base strength value of the skill
|
||||||
|
# @param skill_level [Integer] The current skill level (1-5)
|
||||||
|
# @return [Numeric, nil] The calculated value
|
||||||
|
def calculate_value(base_strength, skill_level)
|
||||||
|
return base_strength if growth.nil?
|
||||||
|
|
||||||
|
base_strength + (growth * (skill_level - 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Format a value with the appropriate suffix
|
||||||
|
# @param value [Numeric] The value to format
|
||||||
|
# @param locale [Symbol] :en or :jp
|
||||||
|
# @return [String] The formatted value with suffix
|
||||||
|
def format_value(value, locale = :en)
|
||||||
|
suffix = locale == :jp ? suffix_jp : suffix_en
|
||||||
|
"#{value}#{suffix}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a strength value is valid for this skill
|
||||||
|
# @param strength [Numeric] The strength value to validate
|
||||||
|
# @return [Boolean]
|
||||||
|
def valid_strength?(strength)
|
||||||
|
return true if base_values.include?(nil) # Unknown values are always valid
|
||||||
|
|
||||||
|
base_values.include?(strength)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the base strength value for a given quality tier
|
||||||
|
# @param quality [Integer] The quality tier (1-5)
|
||||||
|
# @return [Numeric, nil] The base strength value
|
||||||
|
def strength_for_quality(quality)
|
||||||
|
return nil if base_values.nil? || !base_values.is_a?(Array) || base_values.empty?
|
||||||
|
|
||||||
|
# Quality 1-5 maps to index 0-4
|
||||||
|
index = (quality - 1).clamp(0, base_values.size - 1)
|
||||||
|
base_values[index]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
class Character < ApplicationRecord
|
class Character < ApplicationRecord
|
||||||
include PgSearch::Model
|
include PgSearch::Model
|
||||||
|
|
||||||
|
has_many :character_series_memberships, dependent: :destroy
|
||||||
|
has_many :character_series_records, through: :character_series_memberships, source: :character_series
|
||||||
|
|
||||||
multisearchable against: %i[name_en name_jp],
|
multisearchable against: %i[name_en name_jp],
|
||||||
additional_attributes: lambda { |character|
|
additional_attributes: lambda { |character|
|
||||||
{
|
{
|
||||||
|
|
@ -41,6 +44,20 @@ class Character < ApplicationRecord
|
||||||
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
|
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :season,
|
||||||
|
numericality: { only_integer: true },
|
||||||
|
inclusion: { in: GranblueEnums::CHARACTER_SEASONS.values },
|
||||||
|
allow_nil: true
|
||||||
|
|
||||||
|
validate :validate_series_values
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :by_season, ->(season) { where(season: season) }
|
||||||
|
scope :by_series, ->(series) { where('? = ANY(series)', series) }
|
||||||
|
scope :seasonal, -> { where.not(season: [nil, GranblueEnums::CHARACTER_SEASONS[:Standard]]) }
|
||||||
|
|
||||||
def blueprint
|
def blueprint
|
||||||
CharacterBlueprint
|
CharacterBlueprint
|
||||||
end
|
end
|
||||||
|
|
@ -48,4 +65,93 @@ class Character < ApplicationRecord
|
||||||
def display_resource(character)
|
def display_resource(character)
|
||||||
character.name_en
|
character.name_en
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper methods
|
||||||
|
def seasonal?
|
||||||
|
season.present? && season != GranblueEnums::CHARACTER_SEASONS[:Standard]
|
||||||
|
end
|
||||||
|
|
||||||
|
def season_name
|
||||||
|
return nil if season.nil?
|
||||||
|
|
||||||
|
GranblueEnums::CHARACTER_SEASONS.key(season)&.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def series_names
|
||||||
|
# Use new lookup table if available
|
||||||
|
if character_series_records.loaded? ? character_series_records.any? : character_series_records.exists?
|
||||||
|
character_series_records.ordered.pluck(:name_en)
|
||||||
|
elsif series.present?
|
||||||
|
# Legacy fallback
|
||||||
|
series.filter_map { |s| GranblueEnums::CHARACTER_SERIES.key(s)&.to_s }
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def series_objects
|
||||||
|
character_series_records.ordered
|
||||||
|
end
|
||||||
|
|
||||||
|
def series_slugs
|
||||||
|
character_series_records.pluck(:slug)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mapping from legacy integer values to slugs
|
||||||
|
LEGACY_SERIES_TO_SLUG = {
|
||||||
|
1 => 'grand',
|
||||||
|
2 => 'zodiac',
|
||||||
|
3 => 'promo',
|
||||||
|
4 => 'collab',
|
||||||
|
5 => 'eternal',
|
||||||
|
6 => 'evoker',
|
||||||
|
7 => 'saint',
|
||||||
|
8 => 'fantasy',
|
||||||
|
9 => 'summer',
|
||||||
|
10 => 'yukata',
|
||||||
|
11 => 'valentine',
|
||||||
|
12 => 'halloween',
|
||||||
|
13 => 'formal',
|
||||||
|
14 => 'holiday',
|
||||||
|
15 => 'event'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Virtual attribute to set character_series by array of IDs, slugs, or legacy integers
|
||||||
|
# Supports multiple formats for flexibility during migration
|
||||||
|
def series=(values)
|
||||||
|
return if values.blank?
|
||||||
|
|
||||||
|
# Ensure it's an array
|
||||||
|
values = Array(values)
|
||||||
|
|
||||||
|
values.each do |value|
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
|
# Try to find the series record
|
||||||
|
series_record = if value.is_a?(Integer)
|
||||||
|
# Legacy integer - convert to slug first
|
||||||
|
slug = LEGACY_SERIES_TO_SLUG[value]
|
||||||
|
slug ? CharacterSeries.find_by(slug: slug) : nil
|
||||||
|
else
|
||||||
|
# String - try UUID first, then slug
|
||||||
|
CharacterSeries.find_by(id: value) || CharacterSeries.find_by(slug: value)
|
||||||
|
end
|
||||||
|
|
||||||
|
next unless series_record
|
||||||
|
|
||||||
|
# Create membership if it doesn't exist
|
||||||
|
character_series_memberships.find_or_initialize_by(character_series: series_record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_series_values
|
||||||
|
return if series.blank?
|
||||||
|
|
||||||
|
invalid_values = series.reject { |s| GranblueEnums::CHARACTER_SERIES.values.include?(s) }
|
||||||
|
return if invalid_values.empty?
|
||||||
|
|
||||||
|
errors.add(:series, "contains invalid values: #{invalid_values.join(', ')}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
24
app/models/character_series.rb
Normal file
24
app/models/character_series.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CharacterSeries < ApplicationRecord
|
||||||
|
has_many :character_series_memberships, dependent: :destroy
|
||||||
|
has_many :characters, through: :character_series_memberships
|
||||||
|
|
||||||
|
validates :name_en, presence: true
|
||||||
|
validates :name_jp, presence: true
|
||||||
|
validates :slug, presence: true, uniqueness: true
|
||||||
|
validates :order, numericality: { only_integer: true }
|
||||||
|
|
||||||
|
scope :ordered, -> { order(:order) }
|
||||||
|
|
||||||
|
# Slug constants for commonly referenced series
|
||||||
|
GRAND = 'grand'
|
||||||
|
ZODIAC = 'zodiac'
|
||||||
|
ETERNAL = 'eternal'
|
||||||
|
EVOKER = 'evoker'
|
||||||
|
SAINT = 'saint'
|
||||||
|
|
||||||
|
def blueprint
|
||||||
|
CharacterSeriesBlueprint
|
||||||
|
end
|
||||||
|
end
|
||||||
8
app/models/character_series_membership.rb
Normal file
8
app/models/character_series_membership.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CharacterSeriesMembership < ApplicationRecord
|
||||||
|
belongs_to :character
|
||||||
|
belongs_to :character_series
|
||||||
|
|
||||||
|
validates :character_id, uniqueness: { scope: :character_series_id }
|
||||||
|
end
|
||||||
92
app/models/collection_artifact.rb
Normal file
92
app/models/collection_artifact.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CollectionArtifact < ApplicationRecord
|
||||||
|
include ArtifactSkillValidations
|
||||||
|
|
||||||
|
# Associations
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :artifact
|
||||||
|
|
||||||
|
has_many :grid_artifacts, dependent: :nullify
|
||||||
|
|
||||||
|
before_destroy :orphan_grid_items
|
||||||
|
|
||||||
|
# Enums - using GranblueEnums::ELEMENTS values (excluding Null)
|
||||||
|
# Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6
|
||||||
|
enum :element, {
|
||||||
|
wind: 1,
|
||||||
|
fire: 2,
|
||||||
|
water: 3,
|
||||||
|
earth: 4,
|
||||||
|
dark: 5,
|
||||||
|
light: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proficiency enum - only used for quirk artifacts (game assigns random proficiency)
|
||||||
|
enum :proficiency, {
|
||||||
|
sabre: 1,
|
||||||
|
dagger: 2,
|
||||||
|
axe: 3,
|
||||||
|
spear: 4,
|
||||||
|
bow: 5,
|
||||||
|
staff: 6,
|
||||||
|
melee: 7,
|
||||||
|
harp: 8,
|
||||||
|
gun: 9,
|
||||||
|
katana: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :element, presence: true
|
||||||
|
validates :level, presence: true, inclusion: { in: 1..5 }
|
||||||
|
validates :nickname, length: { maximum: 50 }, allow_blank: true
|
||||||
|
validates :proficiency, presence: true, if: :quirk_artifact?
|
||||||
|
validates :proficiency, absence: true, unless: :quirk_artifact?
|
||||||
|
validates :reroll_slot, inclusion: { in: 1..4 }, allow_nil: true
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :by_element, ->(el) { where(element: el) }
|
||||||
|
scope :by_artifact, ->(artifact_id) { where(artifact_id: artifact_id) }
|
||||||
|
# Filter by proficiency - handles both quirk (instance) and standard (artifact) proficiencies
|
||||||
|
scope :by_proficiency, ->(prof) {
|
||||||
|
joins(:artifact).where(
|
||||||
|
'collection_artifacts.proficiency IN (?) OR (collection_artifacts.proficiency IS NULL AND artifacts.proficiency IN (?))',
|
||||||
|
Array(prof), Array(prof)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scope :by_rarity, ->(rar) { joins(:artifact).where(artifacts: { rarity: rar }) }
|
||||||
|
scope :standard_only, -> { joins(:artifact).where(artifacts: { rarity: :standard }) }
|
||||||
|
scope :quirk_only, -> { joins(:artifact).where(artifacts: { rarity: :quirk }) }
|
||||||
|
|
||||||
|
# Filter by skill modifier in a specific slot (1-4)
|
||||||
|
# Uses OR logic when multiple modifiers are provided
|
||||||
|
scope :with_skill_in_slot, ->(slot, modifiers) {
|
||||||
|
return all if modifiers.blank?
|
||||||
|
|
||||||
|
modifiers = Array(modifiers).map(&:to_s)
|
||||||
|
column = "skill#{slot}"
|
||||||
|
|
||||||
|
# Build OR conditions for multiple modifiers
|
||||||
|
conditions = modifiers.map { |_| "#{column}->>'modifier' = ?" }.join(' OR ')
|
||||||
|
where(conditions, *modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the effective proficiency - from instance for quirk, from artifact for standard
|
||||||
|
def effective_proficiency
|
||||||
|
quirk_artifact? ? proficiency : artifact&.proficiency
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def quirk_artifact?
|
||||||
|
artifact&.quirk?
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Marks all linked grid artifacts as orphaned before destroying this collection artifact.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def orphan_grid_items
|
||||||
|
grid_artifacts.update_all(orphaned: true, collection_artifact_id: nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
92
app/models/collection_character.rb
Normal file
92
app/models/collection_character.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
class CollectionCharacter < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :character
|
||||||
|
belongs_to :awakening, optional: true
|
||||||
|
|
||||||
|
before_save :add_default_awakening
|
||||||
|
|
||||||
|
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 :by_race, ->(races) {
|
||||||
|
joins(:character).where('characters.race1 IN (?) OR characters.race2 IN (?)', races, races)
|
||||||
|
}
|
||||||
|
scope :by_proficiency, ->(proficiencies) {
|
||||||
|
joins(:character).where('characters.proficiency1 IN (?) OR characters.proficiency2 IN (?)', proficiencies, proficiencies)
|
||||||
|
}
|
||||||
|
scope :by_gender, ->(genders) { joins(:character).where(characters: { gender: genders }) }
|
||||||
|
scope :transcended, -> { where('transcendence_step > 0') }
|
||||||
|
scope :with_awakening, -> { where.not(awakening_id: nil) }
|
||||||
|
|
||||||
|
# Sorting scopes
|
||||||
|
scope :sorted_by, ->(sort_key) {
|
||||||
|
case sort_key
|
||||||
|
when 'name_asc'
|
||||||
|
joins(:character).order('characters.name_en ASC NULLS LAST')
|
||||||
|
when 'name_desc'
|
||||||
|
joins(:character).order('characters.name_en DESC NULLS LAST')
|
||||||
|
when 'element_asc'
|
||||||
|
joins(:character).order('characters.element ASC')
|
||||||
|
when 'element_desc'
|
||||||
|
joins(:character).order('characters.element DESC')
|
||||||
|
when 'proficiency_asc'
|
||||||
|
joins(:character).order('characters.proficiency1 ASC')
|
||||||
|
when 'proficiency_desc'
|
||||||
|
joins(:character).order('characters.proficiency1 DESC')
|
||||||
|
else
|
||||||
|
order(created_at: :desc) # Default: newest first
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def add_default_awakening
|
||||||
|
return unless awakening.nil?
|
||||||
|
|
||||||
|
self.awakening = Awakening.where(slug: 'character-balanced').sole
|
||||||
|
end
|
||||||
|
end
|
||||||
14
app/models/collection_job_accessory.rb
Normal file
14
app/models/collection_job_accessory.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
class CollectionJobAccessory < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :job_accessory
|
||||||
|
|
||||||
|
validates :job_accessory_id, uniqueness: { scope: :user_id,
|
||||||
|
message: "already exists in your collection" }
|
||||||
|
|
||||||
|
scope :by_job, ->(job_id) { joins(:job_accessory).where(job_accessories: { job_id: job_id }) }
|
||||||
|
scope :by_job_accessory, ->(job_accessory_id) { where(job_accessory_id: job_accessory_id) }
|
||||||
|
|
||||||
|
def blueprint
|
||||||
|
Api::V1::CollectionJobAccessoryBlueprint
|
||||||
|
end
|
||||||
|
end
|
||||||
46
app/models/collection_summon.rb
Normal file
46
app/models/collection_summon.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
class CollectionSummon < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :summon
|
||||||
|
|
||||||
|
has_many :grid_summons, dependent: :nullify
|
||||||
|
|
||||||
|
before_destroy :orphan_grid_items
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
##
|
||||||
|
# Marks all linked grid summons as orphaned before destroying this collection summon.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def orphan_grid_items
|
||||||
|
grid_summons.update_all(orphaned: true, collection_summon_id: nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
177
app/models/collection_weapon.rb
Normal file
177
app/models/collection_weapon.rb
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
belongs_to :ax_modifier1, class_name: 'WeaponStatModifier', optional: true
|
||||||
|
belongs_to :ax_modifier2, class_name: 'WeaponStatModifier', optional: true
|
||||||
|
belongs_to :befoulment_modifier, class_name: 'WeaponStatModifier', optional: true
|
||||||
|
|
||||||
|
has_many :grid_weapons, dependent: :nullify
|
||||||
|
|
||||||
|
before_destroy :orphan_grid_items
|
||||||
|
|
||||||
|
# Set defaults before validation so database defaults don't cause validation failures
|
||||||
|
attribute :awakening_level, :integer, default: 1
|
||||||
|
|
||||||
|
validates :uncap_level, inclusion: { in: 0..5 }
|
||||||
|
validates :transcendence_step, inclusion: { in: 0..10 }
|
||||||
|
validates :awakening_level, inclusion: { in: 1..20 }
|
||||||
|
validates :exorcism_level, inclusion: { in: 0..5 }, allow_nil: true
|
||||||
|
|
||||||
|
validate :validate_weapon_keys
|
||||||
|
validate :validate_ax_skills
|
||||||
|
validate :validate_befoulment
|
||||||
|
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_id) { joins(:weapon).where(weapons: { weapon_series_id: series_id }) }
|
||||||
|
scope :with_keys, -> { where.not(weapon_key1_id: nil) }
|
||||||
|
scope :with_ax, -> { where.not(ax_modifier1_id: nil) }
|
||||||
|
scope :by_element, ->(element) { joins(:weapon).where(weapons: { element: element }) }
|
||||||
|
scope :by_rarity, ->(rarity) { joins(:weapon).where(weapons: { rarity: rarity }) }
|
||||||
|
scope :by_proficiency, ->(proficiency) { joins(:weapon).where(weapons: { proficiency: proficiency }) }
|
||||||
|
scope :transcended, -> { where('transcendence_step > 0') }
|
||||||
|
scope :with_awakening, -> { where.not(awakening_id: nil) }
|
||||||
|
|
||||||
|
scope :sorted_by, ->(sort_key) {
|
||||||
|
case sort_key
|
||||||
|
when 'name_asc'
|
||||||
|
joins(:weapon).order('weapons.name_en ASC NULLS LAST')
|
||||||
|
when 'name_desc'
|
||||||
|
joins(:weapon).order('weapons.name_en DESC NULLS LAST')
|
||||||
|
when 'element_asc'
|
||||||
|
joins(:weapon).order('weapons.element ASC')
|
||||||
|
when 'element_desc'
|
||||||
|
joins(:weapon).order('weapons.element DESC')
|
||||||
|
when 'proficiency_asc'
|
||||||
|
joins(:weapon).order('weapons.proficiency ASC')
|
||||||
|
when 'proficiency_desc'
|
||||||
|
joins(:weapon).order('weapons.proficiency DESC')
|
||||||
|
else
|
||||||
|
order(created_at: :desc)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
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? && !weapon.opus_or_draconic?
|
||||||
|
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
|
||||||
|
# AX skill 1: must have both modifier and strength
|
||||||
|
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
|
||||||
|
|
||||||
|
# AX skill 2: must have both modifier and strength
|
||||||
|
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
|
||||||
|
|
||||||
|
# Validate category is 'ax'
|
||||||
|
if ax_modifier1.present? && ax_modifier1.category != 'ax'
|
||||||
|
errors.add(:ax_modifier1, "must be an AX skill modifier")
|
||||||
|
end
|
||||||
|
if ax_modifier2.present? && ax_modifier2.category != 'ax'
|
||||||
|
errors.add(:ax_modifier2, "must be an AX skill modifier")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_befoulment
|
||||||
|
# Befoulment: must have both modifier and strength
|
||||||
|
if (befoulment_modifier.present? && befoulment_strength.blank?) ||
|
||||||
|
(befoulment_modifier.blank? && befoulment_strength.present?)
|
||||||
|
errors.add(:base, "Befoulment must have both modifier and strength")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate category is 'befoulment'
|
||||||
|
if befoulment_modifier.present? && befoulment_modifier.category != 'befoulment'
|
||||||
|
errors.add(:befoulment_modifier, "must be a befoulment modifier")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Exorcism level only makes sense with befoulment
|
||||||
|
if exorcism_level.present? && exorcism_level > 0 && befoulment_modifier.blank?
|
||||||
|
errors.add(:exorcism_level, "cannot be set without a befoulment")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_element_change
|
||||||
|
return unless element.present? && weapon.present?
|
||||||
|
|
||||||
|
unless Weapon.element_changeable?(weapon)
|
||||||
|
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
|
||||||
|
|
||||||
|
##
|
||||||
|
# Marks all linked grid weapons as orphaned before destroying this collection weapon.
|
||||||
|
#
|
||||||
|
# @return [void]
|
||||||
|
def orphan_grid_items
|
||||||
|
grid_weapons.update_all(orphaned: true, collection_weapon_id: nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
108
app/models/concerns/artifact_skill_validations.rb
Normal file
108
app/models/concerns/artifact_skill_validations.rb
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module ArtifactSkillValidations
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
validate :validate_skill1_group_i
|
||||||
|
validate :validate_skill2_group_i
|
||||||
|
validate :validate_skill3_group_ii
|
||||||
|
validate :validate_skill4_group_iii
|
||||||
|
validate :validate_duplicate_skills
|
||||||
|
validate :validate_skill_levels_sum, unless: :quirk_artifact?
|
||||||
|
validate :validate_quirk_artifact_constraints, if: :quirk_artifact?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def quirk_artifact?
|
||||||
|
artifact&.quirk?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_skill_in_group(skill_data, group_number, slot_name)
|
||||||
|
return if skill_data.blank? || skill_data == {}
|
||||||
|
return if quirk_artifact?
|
||||||
|
|
||||||
|
modifier = skill_data['modifier']
|
||||||
|
quality = skill_data['quality']
|
||||||
|
skill_level = skill_data['level']
|
||||||
|
|
||||||
|
unless modifier && quality && skill_level
|
||||||
|
errors.add(slot_name, 'must have modifier, quality, and level')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
skill_def = ArtifactSkill.find_skill(group_number, modifier)
|
||||||
|
unless skill_def
|
||||||
|
errors.add(slot_name, "has invalid modifier #{modifier}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
unless (1..5).cover?(skill_level)
|
||||||
|
errors.add(slot_name, 'level must be between 1 and 5')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate quality is in valid range (1-5)
|
||||||
|
unless (1..5).cover?(quality)
|
||||||
|
errors.add(slot_name, "has invalid quality #{quality}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_skill1_group_i
|
||||||
|
validate_skill_in_group(skill1, 1, :skill1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_skill2_group_i
|
||||||
|
validate_skill_in_group(skill2, 1, :skill2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_skill3_group_ii
|
||||||
|
validate_skill_in_group(skill3, 2, :skill3)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_skill4_group_iii
|
||||||
|
validate_skill_in_group(skill4, 3, :skill4)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_duplicate_skills
|
||||||
|
return if quirk_artifact?
|
||||||
|
|
||||||
|
# Skills 1 and 2 are both from Group I and cannot have the same modifier
|
||||||
|
return if skill1.blank? || skill1 == {} || skill2.blank? || skill2 == {}
|
||||||
|
|
||||||
|
if skill1['modifier'] == skill2['modifier']
|
||||||
|
errors.add(:base, 'Skill 1 and Skill 2 cannot have the same modifier')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_skill_levels_sum
|
||||||
|
# For standard artifacts, skill levels must sum to (artifact_level + 3)
|
||||||
|
# At level 1: all skills level 1, sum = 4
|
||||||
|
# At level 5: skills sum = 8 (distributed among 4 skills)
|
||||||
|
return if level.nil?
|
||||||
|
|
||||||
|
skills = [skill1, skill2, skill3, skill4]
|
||||||
|
|
||||||
|
# Skip validation if any skill is empty (incomplete artifact)
|
||||||
|
return if skills.any? { |s| s.blank? || s == {} }
|
||||||
|
|
||||||
|
total = skills.sum { |s| s['level'].to_i }
|
||||||
|
expected = level + 3
|
||||||
|
|
||||||
|
return if total == expected
|
||||||
|
|
||||||
|
errors.add(:base, "Skill levels must sum to #{expected} for artifact level #{level}, got #{total}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_quirk_artifact_constraints
|
||||||
|
errors.add(:level, 'must be 1 for quirk artifacts') unless level == 1
|
||||||
|
|
||||||
|
# Quirk artifacts don't store skills
|
||||||
|
[skill1, skill2, skill3, skill4].each_with_index do |skill, idx|
|
||||||
|
next if skill.blank? || skill == {}
|
||||||
|
|
||||||
|
errors.add(:"skill#{idx + 1}", 'must be empty for quirk artifacts')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue