import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' import { PROFESSION_SKILLS, TALENT_TABS, SPEC_ICONS } from './wow-data' import { promises as fs } from 'fs' import path from 'path' import talentLayout from './data/talent-layout.json' // Armería: búsqueda de personajes/ítems/hermandades y ficha de personaje con equipo. // Datos de personaje/equipo en acore_characters; datos de ítem (nombre/calidad/ // display_id) en django_wow.item_data (importado de los db2; item_template no existe // en 3.4.3). Se resuelve en dos pasos para no depender de joins entre esquemas. export interface CharacterResult { guid: number name: string race: number gender: number class: number level: number } export interface ItemResult { entry: number name: string quality: number itemLevel: number inventoryType: number } export interface GuildResult { guildid: number name: string members: number } export interface EquipItem { slot: number entry: number ench: number gems: number[] name: string quality: number itemLevel: number inventoryType: number displayId: number | null transmogEntry: number | null transmogDisplayId: number | null } const LIMIT = 25 export async function searchCharacters(q: string, limit = LIMIT): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT guid, name, race, gender, class, level FROM characters WHERE LOWER(name) LIKE ? ORDER BY level DESC, name LIMIT ?', [`%${q.toLowerCase()}%`, limit], ) return rows.map((r) => ({ guid: r.guid, name: r.name, race: r.race, gender: r.gender, class: r.class, level: r.level, })) } catch { return [] } } export async function searchGuilds(q: string, limit = LIMIT): Promise { try { const [rows] = await db(DB.characters).query( `SELECT g.guildid, g.name, (SELECT COUNT(*) FROM guild_member gm WHERE gm.guildid = g.guildid) AS members FROM guild g WHERE LOWER(g.name) LIKE ? ORDER BY members DESC LIMIT ?`, [`%${q.toLowerCase()}%`, limit], ) return rows.map((r) => ({ guildid: r.guildid, name: r.name, members: Number(r.members ?? 0) })) } catch { return [] } } export async function searchItems(q: string, locale: string, limit = LIMIT): Promise { const col = locale === 'en' ? 'name_en' : 'name_es' try { const [rows] = await db(DB.default).query( `SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type FROM item_data WHERE LOWER(${col}) LIKE ? OR LOWER(name_en) LIKE ? ORDER BY item_level DESC LIMIT ?`, [`%${q.toLowerCase()}%`, `%${q.toLowerCase()}%`, limit], ) return rows.map((r) => ({ entry: r.entry, name: r.name, quality: r.quality, itemLevel: r.item_level, inventoryType: r.inventory_type, })) } catch { return [] } } /** Sugerencias para el autocompletar: pocos resultados de cada tipo. */ export async function suggest( q: string, locale: string, ): Promise<{ characters: CharacterResult[]; items: ItemResult[]; guilds: GuildResult[] }> { const [characters, items, guilds] = await Promise.all([ searchCharacters(q, 6), searchItems(q, locale, 6), searchGuilds(q, 5), ]) return { characters, items, guilds } } export async function getCharacter(guid: number): Promise< (CharacterResult & { guildName: string | null; guildId: number | null; online: boolean; health: number; power: number }) | null > { try { const [rows] = await db(DB.characters).query( 'SELECT guid, name, race, gender, class, level, online, health, power1 FROM characters WHERE guid = ?', [guid], ) const c = rows[0] if (!c) return null let guildName: string | null = null let guildId: number | null = null try { const [g] = await db(DB.characters).query( `SELECT gu.guildid, gu.name FROM guild_member gm JOIN guild gu ON gu.guildid = gm.guildid WHERE gm.guid = ?`, [guid], ) if (g[0]) { guildId = g[0].guildid guildName = g[0].name } } catch { /* sin hermandad */ } return { guid: c.guid, name: c.name, race: c.race, gender: c.gender, class: c.class, level: c.level, online: c.online === 1, health: Number(c.health) || 0, power: Number(c.power1) || 0, guildName, guildId, } } catch { return null } } /** Ítems equipados (slots 0-18) del personaje, resueltos con item_data. */ export async function getCharacterEquipment(guid: number, locale: string): Promise { const col = locale === 'en' ? 'name_en' : 'name_es' try { const [inv] = await db(DB.characters).query( `SELECT ci.slot, ii.itemEntry, ii.transmogrification, ii.enchantments FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item WHERE ci.guid = ? AND ci.bag = 0 AND ci.slot <= 18 ORDER BY ci.slot`, [guid], ) if (inv.length === 0) return [] // Resolver todos los entries (equipados + transmog) en una consulta a item_data. const entries = [ ...new Set(inv.flatMap((r) => [Number(r.itemEntry), Number(r.transmogrification)]).filter((n) => n > 0)), ] const meta = new Map() if (entries.length) { const [items] = await db(DB.default).query( `SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type, display_id FROM item_data WHERE entry IN (${entries.map(() => '?').join(',')})`, entries, ) for (const it of items) meta.set(Number(it.entry), it) } return inv.map((r) => { const entry = Number(r.itemEntry) const tmog = Number(r.transmogrification) || null const m = meta.get(entry) const tm = tmog ? meta.get(tmog) : undefined const { ench, gems } = parseEnchants(r.enchantments) return { slot: r.slot, entry, ench, gems, name: m?.name ?? `#${entry}`, quality: m ? Number(m.quality) : 0, itemLevel: m ? Number(m.item_level) : 0, inventoryType: m ? Number(m.inventory_type) : 0, displayId: m && m.display_id != null ? Number(m.display_id) : null, transmogEntry: tmog, transmogDisplayId: tm && tm.display_id != null ? Number(tm.display_id) : null, } }) } catch { return [] } } export interface GuildMember { guid: number name: string race: number class: number level: number rank: number } export async function getGuild( guildid: number, ): Promise<{ guildid: number; name: string; members: GuildMember[]; leaderName: string | null } | null> { try { const [g] = await db(DB.characters).query( 'SELECT guildid, name, leaderguid FROM guild WHERE guildid = ?', [guildid], ) if (!g[0]) return null const [members] = await db(DB.characters).query( `SELECT c.guid, c.name, c.race, c.class, c.level, gm.rank FROM guild_member gm JOIN characters c ON c.guid = gm.guid WHERE gm.guildid = ? ORDER BY gm.rank, c.level DESC`, [guildid], ) const list = members.map((m) => ({ guid: m.guid, name: m.name, race: m.race, class: m.class, level: m.level, rank: m.rank, })) const leader = list.find((m) => m.guid === g[0].leaderguid) return { guildid: g[0].guildid, name: g[0].name, members: list, leaderName: leader?.name ?? null } } catch { return null } } export interface CharacterCustomization { optionId: number choiceId: number } /** Apariencia del personaje (piel, cara, pelo, marcas...) del sistema de choices de 3.4.3. */ export async function getCharacterCustomizations(guid: number): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT chrCustomizationOptionID AS optionId, chrCustomizationChoiceID AS choiceId FROM character_customizations WHERE guid = ? ORDER BY chrCustomizationOptionID', [guid], ) return rows.map((r) => ({ optionId: Number(r.optionId), choiceId: Number(r.choiceId) })) } catch { return [] } } /** Parsea item_instance.enchantments (grupos de 3: id, duración, cargas). */ function parseEnchants(raw: unknown): { ench: number; gems: number[] } { const parts = String(raw ?? '') .trim() .split(/\s+/) .map((n) => Number(n) || 0) const at = (slot: number) => parts[slot * 3] || 0 const ench = at(0) // PERM_ENCHANTMENT_SLOT const gems = [at(2), at(3), at(4), at(6)].filter((g) => g > 0) // sockets 1-3 + prismático return { ench, gems } } export interface CharacterStats { maxhealth: number maxpower1: number strength: number agility: number stamina: number intellect: number armor: number resHoly: number resFire: number resNature: number resFrost: number resShadow: number resArcane: number blockPct: number dodgePct: number parryPct: number critPct: number rangedCritPct: number spellCritPct: number attackPower: number rangedAttackPower: number spellPower: number resilience: number } /** Instantánea de stats (character_stats). null si el core no la ha guardado aún. */ export async function getCharacterStats(guid: number): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT * FROM character_stats WHERE guid = ?', [guid], ) const s = rows[0] if (!s) return null const n = (v: unknown) => Number(v) || 0 return { maxhealth: n(s.maxhealth), maxpower1: n(s.maxpower1), strength: n(s.strength), agility: n(s.agility), stamina: n(s.stamina), intellect: n(s.intellect), armor: n(s.armor), resHoly: n(s.resHoly), resFire: n(s.resFire), resNature: n(s.resNature), resFrost: n(s.resFrost), resShadow: n(s.resShadow), resArcane: n(s.resArcane), blockPct: n(s.blockPct), dodgePct: n(s.dodgePct), parryPct: n(s.parryPct), critPct: n(s.critPct), rangedCritPct: n(s.rangedCritPct), spellCritPct: n(s.spellCritPct), attackPower: n(s.attackPower), rangedAttackPower: n(s.rangedAttackPower), spellPower: n(s.spellPower), resilience: n(s.resilience), } } catch { return null } } export interface Profession { skill: number name: string value: number max: number rank: string } // Rango de profesión en WotLK según el tope aprendido. const PROF_RANKS: { cap: number; es: string; en: string }[] = [ { cap: 450, es: 'Gran maestro', en: 'Grand Master' }, { cap: 375, es: 'Maestro', en: 'Master' }, { cap: 300, es: 'Artesano', en: 'Artisan' }, { cap: 225, es: 'Experto', en: 'Expert' }, { cap: 150, es: 'Oficial', en: 'Journeyman' }, { cap: 75, es: 'Aprendiz', en: 'Apprentice' }, ] function professionRank(max: number, locale: string): string { for (const r of PROF_RANKS) if (max >= r.cap) return locale === 'en' ? r.en : r.es return '' } /** Profesiones y skills secundarias del personaje (character_skills). */ export async function getCharacterProfessions(guid: number, locale: string): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT skill, value, max FROM character_skills WHERE guid = ?', [guid], ) const out: Profession[] = [] for (const r of rows) { const info = PROFESSION_SKILLS[Number(r.skill)] if (info) { const max = Number(r.max) out.push({ skill: Number(r.skill), name: locale === 'en' ? info.en : info.es, value: Number(r.value), max, rank: professionRank(max, locale) }) } } return out.sort((a, b) => a.name.localeCompare(b.name)) } catch { return [] } } // ---- Iconos de ítems: se resuelven vía wowhead (XML) y se cachean en disco. ---- const ICON_DIR = process.env.ITEM_ICON_CACHE_DIR || '/root/NightSpire/wmmv-cache/icons' async function resolveIcon(entry: number): Promise { const file = path.join(ICON_DIR, `${entry}.txt`) try { return (await fs.readFile(file, 'utf8')).trim() } catch { /* no cacheado */ } let name = '' try { const xml = await fetch(`https://www.wowhead.com/wotlk/item=${entry}&xml`).then((r) => r.text()) const m = xml.match(/]*>([^<]+)<\/icon>/) name = m ? m[1].trim().toLowerCase() : '' } catch { /* sin conexión: se cachea vacío para no reintentar en bucle */ } try { await fs.mkdir(ICON_DIR, { recursive: true }) await fs.writeFile(file, name) } catch { /* ignorar fallo de escritura */ } return name } /** Devuelve { entry: nombreIcono } para los ítems dados (cacheado en disco). */ export async function getItemIcons(entries: number[]): Promise> { const uniq = [...new Set(entries.filter((e) => e > 0))] const out: Record = {} await Promise.all( uniq.map(async (e) => { const name = await resolveIcon(e) if (name) out[e] = name }), ) return out } /** Suma de puntos de logros del personaje (character_achievement × achievement_points). */ export async function getAchievementPoints(guid: number): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT achievement FROM character_achievement WHERE guid = ?', [guid], ) if (!rows.length) return 0 const ids = rows.map((r) => Number(r.achievement)).filter((n) => n > 0) if (!ids.length) return 0 const [pts] = await db(DB.default).query( `SELECT COALESCE(SUM(points), 0) AS pts FROM achievement_points WHERE id IN (${ids.map(() => '?').join(',')})`, ids, ) return Number(pts[0]?.pts ?? 0) } catch { return 0 } } export interface TalentCell { tier: number col: number rank: number max: number spell: number } export interface TalentTreeData { index: number name: string points: number talents: TalentCell[] } export interface TalentSummary { trees: TalentTreeData[] total: number spec: string | null } type TalentLayout = Record /** Árbol de talentos completo (character_talent + Talent.db2 layout). */ export async function getCharacterTalents(guid: number, classId: number, locale: string): Promise { try { const [cg] = await db(DB.characters).query( 'SELECT activeTalentGroup FROM characters WHERE guid = ?', [guid], ) const group = Number(cg[0]?.activeTalentGroup ?? 0) const [rows] = await db(DB.characters).query( 'SELECT talentId, talentRank FROM character_talent WHERE guid = ? AND talentGroup = ?', [guid, group], ) const rankById = new Map() for (const r of rows) rankById.set(Number(r.talentId), Number(r.talentRank) + 1) const layout = talentLayout as TalentLayout const classTabs = Object.entries(TALENT_TABS) .filter(([, v]) => v.classId === classId) .sort((a, b) => a[1].index - b[1].index) if (!classTabs.length) return null let total = 0 let spec: string | null = null let best = -1 const trees: TalentTreeData[] = classTabs.map(([tabIdStr, info]) => { const tabId = Number(tabIdStr) const talents: TalentCell[] = [] let points = 0 for (const [tidStr, l] of Object.entries(layout)) { if (l.tab !== tabId) continue const rank = rankById.get(Number(tidStr)) ?? 0 if (rank > 0) points += rank talents.push({ tier: l.tier, col: l.col, rank, max: l.max, spell: l.spell }) } talents.sort((a, b) => a.tier - b.tier || a.col - b.col) total += points if (points > best) { best = points spec = locale === 'en' ? info.en : info.es } return { index: info.index, name: locale === 'en' ? info.en : info.es, points, talents } }) return { trees, total, spec: total > 0 ? spec : null } } catch { return null } } // ---- Iconos de hechizos (talentos): wowhead XML, cacheado en disco. ---- const SPELL_ICON_DIR = process.env.SPELL_ICON_CACHE_DIR || '/root/NightSpire/wmmv-cache/spellicons' async function resolveSpellIcon(spell: number): Promise { const file = path.join(SPELL_ICON_DIR, `${spell}.txt`) try { return (await fs.readFile(file, 'utf8')).trim() } catch { /* no cacheado */ } let name = '' try { const data = await fetch(`https://nether.wowhead.com/wotlk/tooltip/spell/${spell}`).then((r) => r.json()) name = String((data as { icon?: string }).icon || '').toLowerCase() } catch { /* sin conexión */ } try { if (name) { await fs.mkdir(SPELL_ICON_DIR, { recursive: true }) await fs.writeFile(file, name) } } catch { /* ignorar */ } return name } /** Iconos de hechizos por id (cacheado en disco), en lotes para no saturar wowhead. */ export async function getSpellIcons(spells: number[]): Promise> { const uniq = [...new Set(spells.filter((s) => s > 0))] const out: Record = {} for (let i = 0; i < uniq.length; i += 8) { const chunk = uniq.slice(i, i + 8) await Promise.all( chunk.map(async (s) => { const n = await resolveSpellIcon(s) if (n) out[s] = n }), ) } return out } export interface SpecInfo { name: string icon: string dist: number[] total: number } /** Especializaciones (ambos grupos de talento: primaria activa + doble spec). */ export async function getCharacterSpecs( guid: number, classId: number, locale: string, ): Promise<{ primary: SpecInfo | null; secondary: SpecInfo | null }> { try { const [cg] = await db(DB.characters).query( 'SELECT activeTalentGroup FROM characters WHERE guid = ?', [guid], ) const active = Number(cg[0]?.activeTalentGroup ?? 0) const [rows] = await db(DB.characters).query( 'SELECT talentId, talentRank, talentGroup FROM character_talent WHERE guid = ?', [guid], ) const layout = talentLayout as TalentLayout const classTabs = Object.entries(TALENT_TABS) .filter(([, v]) => v.classId === classId) .sort((a, b) => a[1].index - b[1].index) const specFor = (group: number): SpecInfo | null => { const byTab = new Map() for (const r of rows) { if (Number(r.talentGroup) !== group) continue const l = layout[String(Number(r.talentId))] if (!l) continue byTab.set(l.tab, (byTab.get(l.tab) ?? 0) + Number(r.talentRank) + 1) } const dist = classTabs.map(([tab]) => byTab.get(Number(tab)) ?? 0) const total = dist.reduce((a, b) => a + b, 0) if (total === 0) return null let bi = 0 for (let i = 1; i < dist.length; i++) if (dist[i] > dist[bi]) bi = i const [tabId, info] = classTabs[bi] return { name: locale === 'en' ? info.en : info.es, icon: SPEC_ICONS[Number(tabId)] ?? '', dist, total } } const other = active === 0 ? 1 : 0 return { primary: specFor(active), secondary: specFor(other) } } catch { return { primary: null, secondary: null } } } // ---- Actividad reciente: logros con fecha (nombre/icono vía wowhead, cacheado). ---- const ACH_CACHE_DIR = process.env.ACH_CACHE_DIR || '/root/NightSpire/wmmv-cache/achievements' const ACH_WH_LOCALE: Record = { es: '6', en: '0' } async function resolveAchievement(id: number, locale: string): Promise<{ name: string; icon: string } | null> { const dir = path.join(ACH_CACHE_DIR, locale) const file = path.join(dir, `${id}.json`) try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { /* no cacheado */ } try { const code = ACH_WH_LOCALE[locale] ?? '0' const data = await fetch(`https://nether.wowhead.com/wotlk/tooltip/achievement/${id}?locale=${code}`).then((r) => r.json()) const name = String((data as { name?: string }).name || '').trim() const icon = String((data as { icon?: string }).icon || '').toLowerCase() if (name || icon) { const info = { name, icon } await fs.mkdir(dir, { recursive: true }) await fs.writeFile(file, JSON.stringify(info)) return info } } catch { /* sin conexión */ } return null } export interface ActivityEntry { id: number name: string icon: string date: number } /** Nº total de logros del personaje (para la paginación de actividad). */ export async function getActivityCount(guid: number): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT COUNT(*) AS n FROM character_achievement WHERE guid = ?', [guid], ) return Number(rows[0]?.n ?? 0) } catch { return 0 } } /** Actividad reciente: logros conseguidos, con fecha. Resuelve en lotes paralelos. */ export async function getRecentActivity(guid: number, locale: string, limit = 8, offset = 0): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT achievement, date FROM character_achievement WHERE guid = ? ORDER BY date DESC LIMIT ? OFFSET ?', [guid, limit, offset], ) const items = rows.map((r) => ({ id: Number(r.achievement), date: Number(r.date) })) const out: ActivityEntry[] = new Array(items.length) for (let i = 0; i < items.length; i += 8) { const chunk = items.slice(i, i + 8) await Promise.all( chunk.map(async (it, j) => { const info = await resolveAchievement(it.id, locale) out[i + j] = { id: it.id, name: info?.name || `#${it.id}`, icon: info?.icon || 'achievement_boss_generic', date: it.date, } }), ) } return out } catch { return [] } } /** Página de logros del personaje con búsqueda por nombre (achievement_names) o ID. */ export async function getActivityPage( guid: number, locale: string, page: number, pageSize: number, q: string, ): Promise<{ entries: ActivityEntry[]; total: number }> { try { const [rows] = await db(DB.characters).query( 'SELECT achievement, date FROM character_achievement WHERE guid = ? ORDER BY date DESC', [guid], ) let items = rows.map((r) => ({ id: Number(r.achievement), date: Number(r.date) })) const query = (q || '').trim() if (query) { const idMatch = /^\d+$/.test(query) ? Number(query) : null const ids = items.map((i) => i.id) const nameIds = new Set() if (ids.length) { const [nrows] = await db(DB.default).query( `SELECT id FROM achievement_names WHERE name LIKE ? AND id IN (${ids.map(() => '?').join(',')})`, [`%${query}%`, ...ids], ) for (const r of nrows) nameIds.add(Number(r.id)) } items = items.filter((it) => nameIds.has(it.id) || (idMatch !== null && it.id === idMatch)) } const total = items.length const start = Math.max(0, (page - 1) * pageSize) const pageItems = items.slice(start, start + pageSize) const entries: ActivityEntry[] = new Array(pageItems.length) for (let i = 0; i < pageItems.length; i += 8) { const chunk = pageItems.slice(i, i + 8) await Promise.all( chunk.map(async (it, j) => { const info = await resolveAchievement(it.id, locale) entries[i + j] = { id: it.id, name: info?.name || `#${it.id}`, icon: info?.icon || 'achievement_boss_generic', date: it.date, } }), ) } return { entries, total } } catch { return { entries: [], total: 0 } } }