/** * Item stats parsed from CommunityDragon item description HTML */ export interface ItemStats { // Offensive stats attackDamage?: number abilityPower?: number attackSpeed?: number criticalStrikeChance?: number criticalStrikeDamage?: number lifeSteal?: number omnivamp?: number physicalVamp?: number spellVamp?: number // Defensive stats health?: number armor?: number magicResist?: number // Resource stats mana?: number baseManaRegen?: number baseHealthRegen?: number // Movement stats moveSpeed?: number // Ability stats abilityHaste?: number // Penetration stats (usually percentages) armorPenetration?: number magicPenetration?: number lethality?: number magicPenetrationFlat?: number // Other percentage stats healAndShieldPower?: number tenacity?: number slowResist?: number } /** * Represents a scaling value (e.g., "5% bonus health") */ export interface ScalingValue { value: number isPercentage: boolean scaleType: | 'mana' | 'health' | 'ap' | 'ad' | 'armor' | 'mr' | 'level' | 'bonusHealth' | 'bonusMana' | 'maxHealth' } /** * Represents a damage value with type */ export interface DamageValue { value: number isPercentage: boolean damageType: 'magic' | 'physical' | 'true' } /** * Represents a colored text segment */ export interface TextSegment { type: | 'text' | 'highlight' | 'passive' | 'active' | 'keyword' | 'keywordMajor' | 'keywordStealth' | 'status' | 'speed' | 'scaleMana' | 'scaleHealth' | 'scaleAP' | 'scaleAD' | 'scaleArmor' | 'scaleMR' | 'scaleLevel' | 'scaleBonusHealth' | 'scaleBonusMana' | 'scaleMaxHealth' | 'spellName' | 'unique' | 'rarityMythic' | 'rarityLegendary' | 'rarityGeneric' | 'magicDamage' | 'physicalDamage' | 'trueDamage' | 'healing' | 'shield' | 'attention' | 'onHit' | 'color' content: string color?: string // For custom color spans scaling?: ScalingValue damage?: DamageValue } /** * Represents an item effect (passive, active, unique, etc.) */ export interface ItemEffect { type: 'passive' | 'active' | 'unique' | 'mythic' | 'legendary' | 'epic' name?: string description: TextSegment[] isUnique?: boolean } /** * Parsed item description structure */ export interface ParsedDescription { stats: ItemStats effects: ItemEffect[] rules?: TextSegment[] flavorText?: TextSegment[] rarity?: 'mythic' | 'legendary' | 'epic' } /** * Stat name mappings from CDragon description text to stat keys */ const STAT_MAPPINGS: Record = { 'Attack Damage': 'attackDamage', 'Ability Power': 'abilityPower', 'Attack Speed': 'attackSpeed', 'Critical Strike Chance': 'criticalStrikeChance', 'Critical Strike Damage': 'criticalStrikeDamage', 'Life Steal': 'lifeSteal', Omnivamp: 'omnivamp', 'Physical Vamp': 'physicalVamp', 'Spell Vamp': 'spellVamp', Health: 'health', Armor: 'armor', 'Magic Resist': 'magicResist', Mana: 'mana', 'Base Mana Regen': 'baseManaRegen', 'Base Health Regen': 'baseHealthRegen', 'Move Speed': 'moveSpeed', 'Ability Haste': 'abilityHaste', 'Armor Penetration': 'armorPenetration', 'Magic Penetration': 'magicPenetration', Lethality: 'lethality', 'Heal and Shield Power': 'healAndShieldPower', Tenacity: 'tenacity', 'Slow Resist': 'slowResist' } /** * Parse a stat value string to a number * Handles both flat values (e.g., "25") and percentages (e.g., "25%") */ function parseStatValue(valueStr: string): { value: number; isPercentage: boolean } { const trimmed = valueStr.trim() const isPercentage = trimmed.includes('%') const numStr = trimmed.replace('%', '').replace(',', '').trim() const value = parseFloat(numStr) return { value, isPercentage } } /** * Extract stats section from the description HTML */ function extractStatsSection(description: string): string | null { const statsMatch = description.match(/(.*?)<\/stats>/s) return statsMatch ? statsMatch[1] : null } /** * Parse individual stat lines from the stats section * Format: value statName */ function parseStatLines( statsSection: string ): Array<{ value: number; isPercentage: boolean; statName: string }> { const results: Array<{ value: number; isPercentage: boolean; statName: string }> = [] // Match patterns like: 25 Move Speed // or: 25% Attack Speed const statRegex = /\s*([^<]+)<\/attention>\s*([^<]+)/g let match while ((match = statRegex.exec(statsSection)) !== null) { const valueStr = match[1] const statName = match[2].trim() const { value, isPercentage } = parseStatValue(valueStr) if (!isNaN(value) && statName) { results.push({ value, isPercentage, statName }) } } return results } /** * Parse item stats from CDragon description HTML * * @param description - The HTML description string from CDragon items.json * @returns Parsed ItemStats object with all recognized stats * * @example * ```ts * const stats = parseItemStats( * ' 25 Move Speed

' * ) * // Returns: { moveSpeed: 25 } * ``` */ export function parseItemStats(description: string): ItemStats { const stats: ItemStats = {} if (!description) { return stats } const statsSection = extractStatsSection(description) if (!statsSection) { return stats } const statLines = parseStatLines(statsSection) for (const { value, statName } of statLines) { const statKey = STAT_MAPPINGS[statName] if (statKey) { // For percentage stats that are stored as decimals (e.g., 25% -> 25) // We store the percentage value as-is for consistency with game data stats[statKey] = value } } return stats } /** * Remove HTML tags and get plain text */ function stripHtmlTags(html: string): string { return html .replace(//gi, '\n') .replace(/<[^>]+>/g, '') .replace(/\s+/g, ' ') .trim() } /** * Parse scaling tags and extract scaling info */ function parseScalingTag( tagName: string, content: string ): { scaling?: ScalingValue; text: string } { const scaleTypeMap: Record = { scaleMana: 'mana', scaleHealth: 'health', scaleAP: 'ap', scaleAD: 'ad', scaleArmor: 'armor', scaleMR: 'mr', scaleLevel: 'level', scaleBonusHealth: 'bonusHealth', scaleBonusMana: 'bonusMana', scaleMaxHealth: 'maxHealth' } const scaleType = scaleTypeMap[tagName] if (!scaleType) { return { text: content } } // Try to extract percentage or flat value const percentMatch = content.match(/(\d+(?:\.\d+)?)\s*%/) const flatMatch = content.match(/(\d+(?:\.\d+)?)/) if (percentMatch) { return { scaling: { value: parseFloat(percentMatch[1]), isPercentage: true, scaleType }, text: content } } else if (flatMatch) { return { scaling: { value: parseFloat(flatMatch[1]), isPercentage: false, scaleType }, text: content } } return { text: content } } /** * Parse damage tags (magicDamage, physicalDamage, trueDamage) */ function parseDamageTag(tagName: string, content: string): { damage?: DamageValue; text: string } { const damageTypeMap: Record = { magicDamage: 'magic', physicalDamage: 'physical', trueDamage: 'true' } const damageType = damageTypeMap[tagName] if (!damageType) { return { text: content } } // Try to extract damage value const percentMatch = content.match(/(\d+(?:\.\d+)?)\s*%/) const flatMatch = content.match(/(\d+(?:\.\d+)?)/) if (percentMatch) { return { damage: { value: parseFloat(percentMatch[1]), isPercentage: true, damageType }, text: content } } else if (flatMatch) { return { damage: { value: parseFloat(flatMatch[1]), isPercentage: false, damageType }, text: content } } return { text: content } } /** * Parse text content with HTML tags into TextSegments */ function parseTextSegments(html: string): TextSegment[] { const segments: TextSegment[] = [] if (!html) return segments // Process the HTML and convert to segments // We'll use a simple state machine approach let remaining = html let currentText = '' // Tag type mappings const tagTypeMap: Record = { passive: 'passive', active: 'active', keyword: 'keyword', keywordMajor: 'keywordMajor', keywordStealth: 'keywordStealth', status: 'status', speed: 'speed', scaleMana: 'scaleMana', scaleHealth: 'scaleHealth', scaleAP: 'scaleAP', scaleAD: 'scaleAD', scaleArmor: 'scaleArmor', scaleMR: 'scaleMR', scaleLevel: 'scaleLevel', scaleBonusHealth: 'scaleBonusHealth', scaleBonusMana: 'scaleBonusMana', scaleMaxHealth: 'scaleMaxHealth', spellName: 'spellName', attention: 'attention', magicDamage: 'magicDamage', physicalDamage: 'physicalDamage', trueDamage: 'trueDamage', healing: 'healing', shield: 'shield', onHit: 'onHit' } // Process tags while (remaining.length > 0) { // Check for opening tag const openMatch = remaining.match(/^<([a-zA-Z]+)(?:\s+color='([^']+)')?\s*>/) if (openMatch) { // Push any accumulated text if (currentText) { segments.push({ type: 'text', content: currentText }) currentText = '' } const tagName = openMatch[1] const color = openMatch[2] const fullTag = openMatch[0] // Find closing tag const closeTag = new RegExp(``, 'i') const closeMatch = remaining.substring(fullTag.length).match(closeTag) if (closeMatch && closeMatch.index !== undefined) { const content = remaining.substring(fullTag.length, fullTag.length + closeMatch.index) const segmentType = tagTypeMap[tagName] || 'text' if (tagName === 'font' && color) { segments.push({ type: 'color', content: stripHtmlTags(content), color }) } else if (tagName.startsWith('scale') && tagTypeMap[tagName]) { const { scaling, text } = parseScalingTag(tagName, content) segments.push({ type: segmentType, content: stripHtmlTags(text), scaling }) } else if (tagName.endsWith('Damage') && tagTypeMap[tagName]) { const { damage, text } = parseDamageTag(tagName, content) segments.push({ type: segmentType, content: stripHtmlTags(text), damage }) } else if (segmentType !== 'text') { segments.push({ type: segmentType, content: stripHtmlTags(content) }) } else { // Unknown tag, just add as text currentText += stripHtmlTags(content) } remaining = remaining.substring( fullTag.length + (closeMatch.index || 0) + closeMatch[0].length ) } else { // No closing tag found, skip the opening tag remaining = remaining.substring(fullTag.length) } } else { // Add character to current text currentText += remaining[0] remaining = remaining.substring(1) } } // Push any remaining text if (currentText) { segments.push({ type: 'text', content: currentText }) } return segments } /** * Extract effects section from description (everything after stats) */ function extractEffectsSection(description: string): string { // Remove stats section first const withoutStats = description.replace(/.*?<\/stats>/s, '') // Remove mainText wrapper const effects = withoutStats .replace(//gi, '') .replace(/<\/mainText>/gi, '') .replace(/.*?<\/rules>/gs, '') // Remove rules for now, handle separately .replace(/.*?<\/flavorText>/gs, '') // Remove flavor for now, handle separately .trim() return effects } /** * Parse effects from description HTML */ function parseEffects(description: string): ItemEffect[] { const effects: ItemEffect[] = [] if (!description) return effects // Extract the effects section (after stats) const effectsSection = extractEffectsSection(description) // Split by
tags to process line by line const lines = effectsSection .split(//gi) .map(l => l.trim()) .filter(l => l) let currentEffect: ItemEffect | null = null for (const line of lines) { if (!line) continue // Check for passive tag at the START of the line (effect header) // Only treat as a new effect if the line starts with the tag or the tag is the only content const passiveMatch = line.match(/^([^<]*)<\/passive>(.*)$/i) if (passiveMatch) { const remainingContent = passiveMatch[2].trim() // If there's content after the tag on the same line, it's part of description // If the tag is the only content, this is just an effect header if (!remainingContent) { // This is an effect header line - start a new effect if (currentEffect) { effects.push(currentEffect) } currentEffect = { type: 'passive', name: passiveMatch[1].trim(), description: [], isUnique: false } continue } // If there's content after, check if it looks like a description (not just a label) // For now, treat lines starting with passive tag as new effects if (currentEffect) { effects.push(currentEffect) } currentEffect = { type: 'passive', name: passiveMatch[1].trim(), description: parseTextSegments(remainingContent), isUnique: false } continue } // Check for active tag at the START of the line const activeMatch = line.match(/^([^<]*)<\/active>(.*)$/i) if (activeMatch) { const remainingContent = activeMatch[2].trim() // Skip lines that are just "ACTIVE" labels (like "(0s)" cooldown indicators) const effectName = activeMatch[1].trim() if (effectName.toUpperCase() === 'ACTIVE' && remainingContent.match(/^\([^)]*\)\s*$/)) { // This is just a cooldown label, skip it continue } if (!remainingContent) { // This is an effect header line - start a new effect if (currentEffect) { effects.push(currentEffect) } currentEffect = { type: 'active', name: effectName, description: [], isUnique: false } continue } // If there's content after, it's the description if (currentEffect) { effects.push(currentEffect) } currentEffect = { type: 'active', name: effectName, description: parseTextSegments(remainingContent), isUnique: false } continue } // Check for unique tag at the START of the line const uniqueMatch = line.match(/^([^<]*)<\/unique>(.*)$/i) if (uniqueMatch) { const remainingContent = uniqueMatch[2].trim() if (currentEffect) { effects.push(currentEffect) } if (!remainingContent) { currentEffect = { type: 'unique', name: uniqueMatch[1].trim() || undefined, description: [], isUnique: true } } else { currentEffect = { type: 'unique', name: uniqueMatch[1].trim() || undefined, description: parseTextSegments(remainingContent), isUnique: true } } continue } // Check for rarity tags const mythicMatch = line.match(/([^<]*)<\/rarityMythic>/i) if (mythicMatch) { if (currentEffect) { effects.push(currentEffect) } // Remove the rarity tag from the line before parsing description const lineWithoutTag = line.replace(/[^<]*<\/rarityMythic>/i, '').trim() currentEffect = { type: 'mythic', name: mythicMatch[1].trim() || undefined, description: parseTextSegments(lineWithoutTag), isUnique: false } continue } const legendaryMatch = line.match(/([^<]*)<\/rarityLegendary>/i) if (legendaryMatch) { if (currentEffect) { effects.push(currentEffect) } // Remove the rarity tag from the line before parsing description const lineWithoutTag = line.replace(/[^<]*<\/rarityLegendary>/i, '').trim() currentEffect = { type: 'legendary', name: legendaryMatch[1].trim() || undefined, description: parseTextSegments(lineWithoutTag), isUnique: false } continue } // If we have a current effect, append this line to its description if (currentEffect) { const lineSegments = parseTextSegments(line) currentEffect.description.push(...lineSegments) } else if (line.trim()) { // Standalone effect without explicit tag - create as passive const segments = parseTextSegments(line) if (segments.length > 0 && segments.some(s => s.content.trim())) { currentEffect = { type: 'passive', description: segments, isUnique: false } } } } // Don't forget the last effect if (currentEffect) { effects.push(currentEffect) } return effects } /** * Parse rules section from description */ function parseRules(description: string): TextSegment[] | undefined { const rulesMatch = description.match(/(.*?)<\/rules>/s) if (!rulesMatch) return undefined return parseTextSegments(rulesMatch[1]) } /** * Parse flavor text section from description */ function parseFlavorText(description: string): TextSegment[] | undefined { const flavorMatch = description.match(/(.*?)<\/flavorText>/s) if (!flavorMatch) return undefined return parseTextSegments(flavorMatch[1]) } /** * Detect item rarity from description */ function detectRarity(description: string): 'mythic' | 'legendary' | 'epic' | undefined { if (//i.test(description)) return 'mythic' if (//i.test(description)) return 'legendary' if (//i.test(description)) return 'epic' return undefined } /** * Parse full item description into structured data * * @param description - The HTML description string from CDragon items.json * @returns ParsedDescription object with stats, effects, rules, and flavor text * * @example * ```ts * const parsed = parseItemDescription( * ' 25 Move Speed

Enhanced Movement: +25 Move Speed
' * ) * // Returns: { stats: { moveSpeed: 25 }, effects: [...], ... } * ``` */ export function parseItemDescription(description: string): ParsedDescription { return { stats: parseItemStats(description), effects: parseEffects(description), rules: parseRules(description), flavorText: parseFlavorText(description), rarity: detectRarity(description) } } /** * Item data structure from CDragon */ export interface CDragonItem { id: number name: string description: string active?: boolean inStore?: boolean from?: number[] to?: number[] categories?: string[] maxStacks?: number requiredChampion?: string requiredAlly?: string price?: number priceTotal?: number iconPath: string } /** * Item with parsed stats */ export interface ItemWithStats extends CDragonItem { stats: ItemStats } /** * Item with fully parsed description */ export interface ItemWithParsedDescription extends CDragonItem { parsedDescription: ParsedDescription } /** * Parse a CDragon item and add parsed stats */ export function parseItem(item: CDragonItem): ItemWithStats { return { ...item, stats: parseItemStats(item.description) } } /** * Parse an array of CDragon items and add parsed stats */ export function parseItems(items: CDragonItem[]): ItemWithStats[] { return items.map(parseItem) } /** * Parse a CDragon item with full description parsing */ export function parseItemFull(item: CDragonItem): ItemWithParsedDescription { return { ...item, parsedDescription: parseItemDescription(item.description) } } /** * Parse an array of CDragon items with full description parsing */ export function parseItemsFull(items: CDragonItem[]): ItemWithParsedDescription[] { return items.map(parseItemFull) }