hensei-api/lib/granblue/parsers/character_skill_parser.rb
Justin Edmund 07e5488e0b Add custom page size support via X-Per-Page header
- 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
2025-09-17 05:44:14 -07:00

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