- Add page_size helper method to read from X-Per-Page header - Set min (1) and max (100) bounds for page sizes - Update all paginated endpoints to use dynamic page size - Maintain backward compatibility with default sizes
620 lines
21 KiB
Ruby
620 lines
21 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Granblue
|
|
module Parsers
|
|
class CharacterSkillParser
|
|
require 'nokogiri'
|
|
|
|
def initialize(character)
|
|
@character = character
|
|
@wiki_data = begin
|
|
# Don't try to parse as JSON - parse as MediaWiki format instead
|
|
extract_wiki_data(@character.wiki_raw)
|
|
rescue StandardError => e
|
|
Rails.logger.error "Error parsing wiki raw data: #{e.message}"
|
|
nil
|
|
end
|
|
@game_data = @character.game_raw_en
|
|
end
|
|
|
|
def extract_wiki_data(wikitext)
|
|
return nil unless wikitext.present?
|
|
|
|
data = {}
|
|
# Extract basic character info from template
|
|
wikitext.scan(/\|(\w+)=([^\n|]+)/) do |key, value|
|
|
data[key] = value.strip
|
|
end
|
|
|
|
# Extract ability count
|
|
if match = wikitext.match(/\|abilitycount=\s*(\d+)/)
|
|
data['abilitycount'] = match[1]
|
|
end
|
|
|
|
# Extract individual abilities
|
|
skill_count = data['abilitycount'].to_i
|
|
(1..skill_count).each do |position|
|
|
# Extract ability icon, name, cooldown, etc.
|
|
extract_skill_data(wikitext, position, data)
|
|
end
|
|
|
|
# Extract charge attack data
|
|
extract_ougi_data(wikitext, data)
|
|
|
|
data
|
|
end
|
|
|
|
def extract_skill_data(wikitext, position, data)
|
|
prefix = "a#{position}"
|
|
|
|
# Extract skill name
|
|
if match = wikitext.match(/\|#{prefix}_name=\s*([^\n|]+)/)
|
|
data["#{prefix}_name"] = match[1].strip
|
|
end
|
|
|
|
# Extract skill cooldown
|
|
if match = wikitext.match(/\|#{prefix}_cd=\s*\{\{InfoCd[^}]*cooldown=(\d+)[^}]*\}\}/)
|
|
data["#{prefix}_cd"] = match[1]
|
|
end
|
|
|
|
# Extract skill description using InfoDes template
|
|
if match = wikitext.match(/\|#{prefix}_effdesc=\s*\{\{InfoDes\|num=\d+\|des=([^|]+)(?:\|[^}]+)?\}\}/)
|
|
data["#{prefix}_effdesc"] = match[1].strip
|
|
end
|
|
|
|
# Check for alt version indicator
|
|
data["#{prefix}_option"] = 'alt' if wikitext.match(/\|#{prefix}_option=alt/)
|
|
|
|
# Extract obtained level
|
|
if (match = wikitext.match(/\|#{prefix}_oblevel=\s*\{\{InfoOb\|obtained=(\d+)(?:\|[^}]+)?\}\}/))
|
|
data["#{prefix}_oblevel"] = "obtained=#{match[1]}"
|
|
end
|
|
|
|
# Extract enhanced level if present
|
|
if (match = wikitext.match(/\|#{prefix}_oblevel=\s*\{\{InfoOb\|obtained=\d+\|enhanced=(\d+)(?:\|[^}]+)?\}\}/))
|
|
data["#{prefix}_oblevel"] += "|enhanced=#{match[1]}"
|
|
end
|
|
end
|
|
|
|
def extract_ougi_data(wikitext, data)
|
|
# Extract charge attack name
|
|
if (match = wikitext.match(/\|ougi_name=\s*([^\n|]+)/))
|
|
data['ougi_name'] = match[1].strip
|
|
end
|
|
|
|
# Extract charge attack description
|
|
if (match = wikitext.match(/\|ougi_desc=\s*([^\n|]+)/))
|
|
data['ougi_desc'] = match[1].strip
|
|
end
|
|
|
|
# Extract FLB/ULB charge attack details if present
|
|
if (match = wikitext.match(/\|ougi2_name=\s*([^\n|]+)/))
|
|
data['ougi2_name'] = match[1].strip
|
|
end
|
|
|
|
return unless (match = wikitext.match(/\|ougi2_desc=\s*([^\n|]+)/))
|
|
|
|
data['ougi2_desc'] = match[1].strip
|
|
end
|
|
|
|
def parse_and_save
|
|
return unless @wiki_data && @game_data
|
|
|
|
# Parse and save skills
|
|
parse_skills
|
|
|
|
# Parse and save charge attack
|
|
parse_charge_attack
|
|
|
|
# Return success status
|
|
true
|
|
rescue StandardError => e
|
|
Rails.logger.error "Error parsing skills for character #{@character.name_en}: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
false
|
|
end
|
|
|
|
private
|
|
|
|
def parse_skills
|
|
# Get ability data from game data
|
|
game_abilities = @game_data['ability'] || {}
|
|
ap 'Game'
|
|
ap game_abilities
|
|
|
|
# Get skill count from wiki data
|
|
skill_count = @wiki_data['abilitycount'].to_i
|
|
ap 'Wiki'
|
|
ap skill_count
|
|
|
|
# Process each skill
|
|
(1..skill_count).each do |position|
|
|
game_skill = game_abilities[position.to_s]
|
|
next unless game_skill
|
|
|
|
# Create or find skill
|
|
skill = Skill.find_or_initialize_by(
|
|
name_en: game_skill['name_en'] || game_skill['name'],
|
|
skill_type: Skill.skill_types[:character]
|
|
)
|
|
|
|
# Set skill attributes
|
|
skill.name_jp = game_skill['name'] if game_skill['name'].present?
|
|
skill.description_en = game_skill['comment_en'] || game_skill['comment']
|
|
skill.description_jp = game_skill['comment'] if game_skill['comment'].present?
|
|
skill.border_type = extract_border_type(game_skill)
|
|
skill.cooldown = game_skill['recast'].to_i if game_skill['recast'].present?
|
|
|
|
# Save skill
|
|
skill.save!
|
|
|
|
# Wiki data for skill
|
|
wiki_skill_key = "a#{position}"
|
|
wiki_skill = @wiki_data[wiki_skill_key] || {}
|
|
|
|
# Create character skill connection
|
|
character_skill = CharacterSkill.find_or_initialize_by(
|
|
character_granblue_id: @character.granblue_id,
|
|
position: position
|
|
)
|
|
|
|
character_skill.skill = skill
|
|
character_skill.unlock_level = wiki_skill["a#{position}_oblevel"]&.match(/obtained=(\d+)/)&.captures&.first&.to_i || 1
|
|
character_skill.improve_level = wiki_skill["a#{position}_oblevel"]&.match(/enhanced=(\d+)/)&.captures&.first&.to_i
|
|
|
|
# Check for alt version
|
|
if game_skill['display_action_ability_info']&.dig('action_ability')&.any?
|
|
# Handle alt version of skill
|
|
alt_action = game_skill['display_action_ability_info']['action_ability'].first
|
|
|
|
alt_skill = Skill.find_or_initialize_by(
|
|
name_en: alt_action['name_en'] || alt_action['name'],
|
|
skill_type: Skill.skill_types[:character]
|
|
)
|
|
|
|
alt_skill.name_jp = alt_action['name'] if alt_action['name'].present?
|
|
alt_skill.description_en = alt_action['comment_en'] || alt_action['comment']
|
|
alt_skill.description_jp = alt_action['comment'] if alt_action['comment'].present?
|
|
alt_skill.border_type = extract_border_type(alt_action)
|
|
alt_skill.cooldown = alt_action['recast'].to_i if alt_action['recast'].present?
|
|
|
|
alt_skill.save!
|
|
|
|
character_skill.alt_skill = alt_skill
|
|
|
|
# Parse condition for alt version
|
|
if wiki_skill['alt_condition'].present?
|
|
character_skill.alt_condition = wiki_skill['alt_condition']
|
|
elsif game_skill['comment_en']&.include?('when')
|
|
# Try to extract condition from comment
|
|
if match = game_skill['comment_en'].match(/\(.*?when\s+(.*?)\s*(?::|$)/i)
|
|
character_skill.alt_condition = match[1]
|
|
end
|
|
end
|
|
end
|
|
|
|
character_skill.save!
|
|
|
|
# Parse and save effects
|
|
parse_effects_for_skill(skill, game_skill)
|
|
|
|
# If alt skill exists, parse its effects too
|
|
if character_skill.alt_skill
|
|
alt_action = game_skill['display_action_ability_info']['action_ability'].first
|
|
parse_effects_for_skill(character_skill.alt_skill, alt_action)
|
|
end
|
|
end
|
|
end
|
|
|
|
def parse_charge_attack
|
|
ap 'Parsing charge attack...'
|
|
# Get charge attack data from wiki and game
|
|
wiki_ougi = {
|
|
'name' => @wiki_data['ougi_name'],
|
|
'desc' => @wiki_data['ougi_desc']
|
|
}
|
|
|
|
# ap @game_data
|
|
game_ougi = @game_data['special_skill']
|
|
ap 'Game ougi:'
|
|
ap game_ougi
|
|
return unless game_ougi
|
|
|
|
puts 'Wiki'
|
|
puts wiki_ougi
|
|
puts 'Game'
|
|
puts game_ougi
|
|
|
|
# Create skill for charge attack
|
|
skill = Skill.find_or_initialize_by(
|
|
name_en: wiki_ougi['name'] || game_ougi['name'],
|
|
skill_type: Skill.skill_types[:charge_attack]
|
|
)
|
|
|
|
skill.name_jp = game_ougi['name'] if game_ougi['name'].present?
|
|
skill.description_en = wiki_ougi['desc'] || game_ougi['comment']
|
|
skill.description_jp = game_ougi['comment'] if game_ougi['comment'].present?
|
|
skill.save!
|
|
|
|
# Create charge attack record
|
|
charge_attack = ChargeAttack.find_or_initialize_by(
|
|
owner_id: @character.granblue_id,
|
|
owner_type: 'character',
|
|
uncap_level: 0
|
|
)
|
|
|
|
charge_attack.skill = skill
|
|
charge_attack.save!
|
|
|
|
# Parse effects for charge attack
|
|
parse_effects_for_charge_attack(skill, wiki_ougi['desc'], game_ougi)
|
|
|
|
# If there are uncapped charge attacks
|
|
return unless @wiki_data['ougi2_name'].present?
|
|
|
|
# Process 5* uncap charge attack
|
|
alt_skill = Skill.find_or_initialize_by(
|
|
name_en: @wiki_data['ougi2_name'],
|
|
skill_type: Skill.skill_types[:charge_attack]
|
|
)
|
|
|
|
alt_skill.description_en = @wiki_data['ougi2_desc']
|
|
alt_skill.save!
|
|
|
|
# Create alt charge attack record
|
|
alt_charge_attack = ChargeAttack.find_or_initialize_by(
|
|
owner_id: @character.granblue_id,
|
|
owner_type: 'character',
|
|
uncap_level: 4 # 5* uncap
|
|
)
|
|
|
|
alt_charge_attack.skill = alt_skill
|
|
alt_charge_attack.save!
|
|
|
|
# Parse effects for alt charge attack
|
|
parse_effects_for_charge_attack(alt_skill, @wiki_data['ougi2_desc'], nil)
|
|
end
|
|
|
|
def parse_effects_for_skill(skill, game_skill)
|
|
# Look for buff/debuff details
|
|
if game_skill['ability_detail'].present?
|
|
# Process buffs
|
|
if game_skill['ability_detail']['buff'].present?
|
|
game_skill['ability_detail']['buff'].each do |buff_data|
|
|
create_effect_from_game_data(skill, buff_data, :buff)
|
|
end
|
|
end
|
|
|
|
# Process debuffs
|
|
if game_skill['ability_detail']['debuff'].present?
|
|
game_skill['ability_detail']['debuff'].each do |debuff_data|
|
|
create_effect_from_game_data(skill, debuff_data, :debuff)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Also try to extract effects from description
|
|
extract_effects_from_description(skill, game_skill['comment_en'] || game_skill['comment'])
|
|
end
|
|
|
|
def parse_effects_for_charge_attack(skill, description, game_ougi)
|
|
# Extract effects from charge attack description
|
|
extract_effects_from_description(skill, description)
|
|
|
|
# If we have game data, try to extract more details
|
|
return unless game_ougi && game_ougi['comment'].present?
|
|
|
|
extract_effects_from_description(skill, game_ougi['comment'])
|
|
end
|
|
|
|
def create_effect_from_game_data(skill, effect_data, effect_type)
|
|
# Extract effect name and status code
|
|
status = effect_data['status']
|
|
detail = effect_data['detail']
|
|
effect_duration = effect_data['effect']
|
|
|
|
# Get effect class (normalized type) from the detail
|
|
effect_class = normalize_effect_class(detail)
|
|
|
|
# Create or find effect
|
|
effect = Effect.find_or_initialize_by(
|
|
name_en: detail,
|
|
effect_type: Effect.effect_types[effect_type]
|
|
)
|
|
|
|
effect.effect_class = effect_class
|
|
effect.save!
|
|
|
|
# Create skill effect connection
|
|
skill_effect = SkillEffect.find_or_initialize_by(
|
|
skill: skill,
|
|
effect: effect
|
|
)
|
|
|
|
# Figure out target type
|
|
target_type = determine_target_type(skill, detail)
|
|
skill_effect.target_type = target_type
|
|
|
|
# Figure out duration
|
|
duration_info = parse_duration(effect_duration)
|
|
skill_effect.duration_type = duration_info[:type]
|
|
skill_effect.duration_value = duration_info[:value]
|
|
|
|
# Other attributes
|
|
skill_effect.value = extract_value_from_detail(detail)
|
|
skill_effect.cap = extract_cap_from_detail(detail)
|
|
skill_effect.permanent = effect_duration.blank? || effect_duration.downcase == 'permanent'
|
|
skill_effect.undispellable = detail.include?("Can't be removed")
|
|
|
|
skill_effect.save!
|
|
end
|
|
|
|
def extract_effects_from_description(skill, description)
|
|
return unless description.present?
|
|
|
|
# Look for status effects in the description with complex pattern matching
|
|
status_pattern = /\{\{status\|([^|}]+)(?:\|([^}]+))?\}\}/
|
|
|
|
description.scan(status_pattern).each do |matches|
|
|
status_name = matches[0].strip
|
|
attrs_text = matches[1]
|
|
|
|
# Create effect
|
|
effect = Effect.find_or_initialize_by(
|
|
name_en: status_name,
|
|
effect_type: determine_effect_type(status_name)
|
|
)
|
|
|
|
effect.effect_class = normalize_effect_class(status_name)
|
|
effect.save!
|
|
|
|
# Create skill effect with attributes
|
|
skill_effect = SkillEffect.find_or_initialize_by(
|
|
skill: skill,
|
|
effect: effect
|
|
)
|
|
|
|
# Parse attributes from the status tag
|
|
if attrs_text.present?
|
|
attrs = {}
|
|
|
|
# Extract duration (t=X)
|
|
if duration_match = attrs_text.match(/t=([^|]+)/)
|
|
attrs[:duration] = duration_match[1]
|
|
end
|
|
|
|
# Extract value (a=X)
|
|
if value_match = attrs_text.match(/a=([^|%]+)/)
|
|
attrs[:value] = value_match[1]
|
|
end
|
|
|
|
# Extract cap
|
|
if cap_match = attrs_text.match(/cap=(\d+)/)
|
|
attrs[:cap] = cap_match[1]
|
|
end
|
|
|
|
# Apply extracted attributes
|
|
skill_effect.target_type = determine_target_type(skill, status_name)
|
|
skill_effect.value = attrs[:value].to_f if attrs[:value].present?
|
|
skill_effect.cap = attrs[:cap].to_i if attrs[:cap].present?
|
|
|
|
# Parse duration
|
|
if attrs[:duration].present?
|
|
duration_info = parse_duration(attrs[:duration])
|
|
skill_effect.duration_type = duration_info[:type]
|
|
skill_effect.duration_value = duration_info[:value]
|
|
end
|
|
|
|
skill_effect.undispellable = attrs_text.include?("can't be removed")
|
|
end
|
|
|
|
skill_effect.save!
|
|
end
|
|
end
|
|
|
|
def extract_border_type(skill_data)
|
|
# Map class_name to border type
|
|
class_name = skill_data['class_name']
|
|
|
|
if class_name.nil?
|
|
nil
|
|
elsif class_name.end_with?('_1')
|
|
Skill.border_types[:damage]
|
|
elsif class_name.end_with?('_2')
|
|
Skill.border_types[:healing]
|
|
elsif class_name.end_with?('_3')
|
|
Skill.border_types[:buff]
|
|
elsif class_name.end_with?('_4')
|
|
Skill.border_types[:debuff]
|
|
elsif class_name.end_with?('_5')
|
|
Skill.border_types[:field]
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def normalize_effect_class(detail)
|
|
# Map common effect descriptions to standardized classes
|
|
return nil unless detail.present?
|
|
|
|
detail = detail.downcase
|
|
|
|
if detail.include?("can't attack") || detail.include?("can't act") || detail.include?('actions for') || detail.include?('actions are sealed')
|
|
'cant_act'
|
|
elsif detail.include?('hp is lowered on every turn') && !detail.include?('putrefied')
|
|
'poison'
|
|
elsif detail.include?('putrefied') || detail.include?('hp is lowered on every turn based on')
|
|
'poison_strong'
|
|
elsif detail.include?('atk is boosted based on how low hp is') || detail.include?('jammed')
|
|
'jammed'
|
|
elsif detail.include?('veil') || detail.include?('debuffs will be nullified')
|
|
'veil'
|
|
elsif detail.include?('mirror') || detail.include?('next attack will miss')
|
|
'mirror_image'
|
|
elsif detail.match?(/dodge.+hit|taking less dmg/i)
|
|
'repel'
|
|
elsif detail.include?('shield') || detail.include?('ineffective for a fixed amount')
|
|
'shield'
|
|
elsif detail.include?('counter') && detail.include?('dodge')
|
|
'counter_on_dodge'
|
|
elsif detail.include?('counter') && detail.include?('dmg')
|
|
'counter_on_damage'
|
|
elsif detail.include?('boost to triple attack') || detail.include?('triple attack rate')
|
|
'ta_up'
|
|
elsif detail.include?('boost to double attack') || detail.include?('double attack rate')
|
|
'da_up'
|
|
elsif detail.include?('boost to charge bar') || detail.include?('charge boost')
|
|
'charge_bar_boost'
|
|
elsif detail.include?('drain') || detail.include?('absorbed to hp')
|
|
'drain'
|
|
elsif detail.include?('bonus') && detail.include?('dmg')
|
|
'echo'
|
|
elsif detail.match?(/atk is (?:sharply )?boosted/i) && !detail.include?('based on')
|
|
'atk_up'
|
|
elsif detail.match?(/def is (?:sharply )?boosted/i) && !detail.include?('based on')
|
|
'def_up'
|
|
else
|
|
# Create a slug from the first few words
|
|
detail.split(/\s+/).first(3).join('_').gsub(/[^a-z0-9_]/i, '').downcase
|
|
end
|
|
end
|
|
|
|
def determine_effect_type(name)
|
|
name = name.downcase
|
|
|
|
if name.include?('down') || name.include?('lower') || name.include?('hit') || name.include?('reduced') ||
|
|
name.include?('blind') || name.include?('petrif') || name.include?('paralyze') || name.include?('stun') ||
|
|
name.include?('charm') || name.include?('poison') || name.include?('putrefied') || name.include?('sleep') ||
|
|
name.include?('fear') || name.include?('delay')
|
|
Effect.effect_types[:debuff]
|
|
else
|
|
Effect.effect_types[:buff]
|
|
end
|
|
end
|
|
|
|
def determine_target_type(skill, detail)
|
|
# Try to determine target type from skill and detail
|
|
if detail.downcase.include?('all allies')
|
|
SkillEffect.target_types[:all_allies]
|
|
elsif detail.downcase.include?('all foes')
|
|
SkillEffect.target_types[:all_enemies]
|
|
elsif detail.downcase.include?('caster') || detail.downcase.include?('own ')
|
|
SkillEffect.target_types[:self]
|
|
elsif skill.border_type == Skill.border_types[:buff] || detail.downcase.include?('allies') || detail.downcase.include?('party')
|
|
SkillEffect.target_types[:ally]
|
|
elsif skill.border_type == Skill.border_types[:debuff] || detail.downcase.include?('foe') || detail.downcase.include?('enemy')
|
|
SkillEffect.target_types[:enemy]
|
|
elsif determine_effect_type(detail) == Effect.effect_types[:buff]
|
|
# Default
|
|
SkillEffect.target_types[:self]
|
|
else
|
|
SkillEffect.target_types[:enemy]
|
|
end
|
|
end
|
|
|
|
def parse_duration(duration_text)
|
|
return { type: SkillEffect.duration_types[:indefinite], value: nil } unless duration_text.present?
|
|
|
|
duration_text = duration_text.downcase
|
|
|
|
if duration_text.include?('turn')
|
|
# Parse turns
|
|
turns = duration_text.scan(/(\d+(?:\.\d+)?)(?:\s*-)?\s*turn/).flatten.first
|
|
{
|
|
type: SkillEffect.duration_types[:turns],
|
|
value: turns.to_f
|
|
}
|
|
elsif duration_text.include?('sec')
|
|
# Parse seconds
|
|
seconds = duration_text.scan(/(\d+)(?:\s*-)?\s*sec/).flatten.first
|
|
{
|
|
type: SkillEffect.duration_types[:seconds],
|
|
value: seconds.to_i
|
|
}
|
|
elsif duration_text.include?('time') || duration_text.include?('hit')
|
|
# Parse one-time
|
|
{ type: SkillEffect.duration_types[:one_time], value: nil }
|
|
else
|
|
# Default to indefinite
|
|
{ type: SkillEffect.duration_types[:indefinite], value: nil }
|
|
end
|
|
end
|
|
|
|
def parse_status_attributes(attr_string)
|
|
result = {
|
|
value: nil,
|
|
cap: nil,
|
|
duration: nil,
|
|
chance: 100, # Default
|
|
options: []
|
|
}
|
|
|
|
# Split attributes
|
|
attrs = attr_string.split('|')
|
|
|
|
attrs.each do |attr|
|
|
if attr.include?('=')
|
|
key, value = attr.split('=', 2)
|
|
key = key.strip
|
|
value = value.strip
|
|
|
|
case key
|
|
when 'a'
|
|
# Value (amount)
|
|
result[:value] = if value.end_with?('%')
|
|
value.delete('%').to_f
|
|
else
|
|
value
|
|
end
|
|
when 'cap'
|
|
# Cap
|
|
result[:cap] = value.gsub(/[^\d]/, '').to_i
|
|
when 't'
|
|
# Duration
|
|
result[:duration] = value
|
|
when 'acc'
|
|
# Accuracy
|
|
result[:chance] = if value == 'Guaranteed'
|
|
100
|
|
else
|
|
value.delete('%').to_i
|
|
end
|
|
else
|
|
# Other options
|
|
result[:options] << "#{key}=#{value}"
|
|
end
|
|
elsif attr == 'i'
|
|
# Simple attributes like "n=1" or just "i"
|
|
result[:duration] = 'indefinite'
|
|
elsif attr.start_with?('n=')
|
|
# Number of hits/times
|
|
# Store in options
|
|
result[:options] << attr.strip
|
|
else
|
|
result[:options] << attr.strip
|
|
end
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def extract_value_from_detail(detail)
|
|
# Extract numeric value from detail text
|
|
if match = detail.match(/(\d+(?:\.\d+)?)%/)
|
|
match[1].to_f
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def extract_cap_from_detail(detail)
|
|
# Extract cap from detail text
|
|
if match = detail.match(/cap(?:ped)?\s*(?:at|:)\s*(\d+(?:,\d+)*)/)
|
|
match[1].gsub(',', '').to_i
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|