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' import glyphProps from './data/glyph-props.json' import mountPetSpells from './data/mount-pet-spells.json' import factionNames from './data/faction-names.json' import achievementsData from './data/achievements.json' import raidsData from './data/raids.json' import dungeonsData from './data/dungeons.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; xp: number; totalKills: number; todayKills: number; yesterdayKills: number; honor: number }) | null > { try { const [rows] = await db(DB.characters).query( 'SELECT guid, name, race, gender, class, level, online, health, power1, xp, totalKills, todayKills, yesterdayKills, honor 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, xp: Number(c.xp) || 0, totalKills: Number(c.totalKills) || 0, todayKills: Number(c.todayKills) || 0, yesterdayKills: Number(c.yesterdayKills) || 0, honor: Number(c.honor) || 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 icon: string kind: 'primary' | 'secondary' } // 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), icon: info.icon, kind: info.kind }) } } 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, filterId: string, filterName: 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 idNum = /^\d+$/.test((filterId || '').trim()) ? Number(filterId) : null if (idNum !== null) items = items.filter((i) => i.id === idNum) const nameQ = (filterName || '').trim() if (nameQ) { 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(',')})`, [`%${nameQ}%`, ...ids], ) for (const r of nrows) nameIds.add(Number(r.id)) } items = items.filter((it) => nameIds.has(it.id)) } 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 } } } /** Nº de misiones completadas (con recompensa) del personaje. */ export async function getQuestCount(guid: number): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT COUNT(*) AS n FROM character_queststatus_rewarded WHERE guid = ?', [guid], ) return Number(rows[0]?.n ?? 0) } catch { return 0 } } // ---- Info de hechizo (nombre + icono) vía wowhead, cacheado por locale. ---- const SPELL_INFO_DIR = process.env.SPELL_INFO_CACHE_DIR || '/root/NightSpire/wmmv-cache/spells' async function resolveSpellInfo(spell: number, locale: string): Promise<{ name: string; icon: string } | null> { const dir = path.join(SPELL_INFO_DIR, locale) const file = path.join(dir, `${spell}.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/spell/${spell}?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 GlyphInfo { name: string icon: string } /** Glifos del personaje (grupo activo). Mayor/menor por el slot (par=mayor, impar=menor). */ export async function getCharacterGlyphs(guid: number, locale: string): Promise<{ major: GlyphInfo[]; minor: GlyphInfo[] }> { 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 glyphSlot, glyphId FROM character_glyphs WHERE guid = ? AND talentGroup = ? ORDER BY glyphSlot', [guid, group], ) const props = glyphProps as Record const major: GlyphInfo[] = [] const minor: GlyphInfo[] = [] for (const r of rows) { const gid = Number(r.glyphId) if (!gid) continue const p = props[String(gid)] if (!p) continue const info = await resolveSpellInfo(p.spell, locale) const g: GlyphInfo = { name: info?.name || `#${gid}`, icon: info?.icon || 'inv_misc_note_01' } if (Number(r.glyphSlot) % 2 === 0) major.push(g) else minor.push(g) } return { major, minor } } catch { return { major: [], minor: [] } } } // ---- Misiones: nombres vía wowhead (cacheado). quest_template no está en la BD. ---- const QUEST_CACHE_DIR = process.env.QUEST_CACHE_DIR || '/root/NightSpire/wmmv-cache/quests' async function resolveQuest(id: number, locale: string): Promise { const dir = path.join(QUEST_CACHE_DIR, locale) const file = path.join(dir, `${id}.txt`) try { return (await fs.readFile(file, 'utf8')).trim() } catch { /* no cacheado */ } let name = '' try { const code = ACH_WH_LOCALE[locale] ?? '0' const data = await fetch(`https://nether.wowhead.com/wotlk/tooltip/quest/${id}?locale=${code}`).then((r) => r.json()) name = String((data as { name?: string }).name || '').trim() } catch { /* sin conexión */ } if (name) { try { await fs.mkdir(dir, { recursive: true }) await fs.writeFile(file, name) } catch { /* ignorar */ } } return name } export type QuestStatus = 'rewarded' | 'complete' | 'inprogress' | 'failed' export interface QuestEntry { id: number name: string status: QuestStatus } async function resolveQuestNames(items: { id: number; status: QuestStatus }[], locale: string): Promise { const out: QuestEntry[] = 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 nm = await resolveQuest(it.id, locale) out[i + j] = { id: it.id, name: nm || `#${it.id}`, status: it.status } }), ) } return out } /** Página de misiones del personaje con filtros por ID, nombre y estado. */ export async function getQuestPage( guid: number, locale: string, page: number, pageSize: number, filterId: string, filterName: string, status: string, ): Promise<{ entries: QuestEntry[]; total: number }> { try { const [rew] = await db(DB.characters).query( 'SELECT quest FROM character_queststatus_rewarded WHERE guid = ?', [guid], ) const [prog] = await db(DB.characters).query( 'SELECT quest, status FROM character_queststatus WHERE guid = ?', [guid], ) const rewSet = new Set(rew.map((r) => Number(r.quest))) let items: { id: number; status: QuestStatus }[] = [] for (const r of rew) items.push({ id: Number(r.quest), status: 'rewarded' }) for (const r of prog) { const q = Number(r.quest) if (rewSet.has(q)) continue const st = Number(r.status) const s: QuestStatus = st === 1 ? 'complete' : st === 5 ? 'failed' : 'inprogress' items.push({ id: q, status: s }) } items.sort((a, b) => a.id - b.id) if (status === 'rewarded' || status === 'complete' || status === 'inprogress' || status === 'failed') items = items.filter((i) => i.status === status) const idNum = /^\d+$/.test((filterId || '').trim()) ? Number(filterId) : null if (idNum !== null) items = items.filter((i) => i.id === idNum) const nameQ = (filterName || '').trim().toLowerCase() if (nameQ) { const resolved = await resolveQuestNames(items, locale) const filtered = resolved.filter((it) => it.name.toLowerCase().includes(nameQ)) const total = filtered.length return { entries: filtered.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize), total } } const total = items.length const pageItems = items.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize) return { entries: await resolveQuestNames(pageItems, locale), total } } catch { return { entries: [], total: 0 } } } export interface CollectionEntry { id: number name: string icon: string } /** Colección de monturas (skill 777) o mascotas (778) del personaje (character_spell). */ export async function getCharacterCollection( guid: number, locale: string, kind: 'mounts' | 'pets', ): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT spell FROM character_spell WHERE guid = ?', [guid], ) const data = mountPetSpells as { mounts: number[]; pets: number[] } const set = new Set(kind === 'mounts' ? data.mounts : data.pets) const ids = [...new Set(rows.map((r) => Number(r.spell)).filter((s) => set.has(s)))] const out: CollectionEntry[] = new Array(ids.length) for (let i = 0; i < ids.length; i += 8) { const chunk = ids.slice(i, i + 8) await Promise.all( chunk.map(async (spell, j) => { const info = await resolveSpellInfo(spell, locale) out[i + j] = { id: spell, name: info?.name || `#${spell}`, icon: info?.icon || 'inv_misc_questionmark' } }), ) } out.sort((a, b) => a.name.localeCompare(b.name)) return out } catch { return [] } } export interface TalentGroupView { spec: string | null total: number dist: number[] trees: TalentTreeData[] glyphs: { major: GlyphInfo[]; minor: GlyphInfo[] } } /** Talentos + glifos de AMBOS grupos (doble spec). Devuelve solo los grupos con puntos. */ export async function getCharacterTalentGroups( guid: number, classId: number, locale: string, ): Promise<{ groups: TalentGroupView[]; activeIndex: number; spellIds: number[] }> { try { const [cg] = await db(DB.characters).query( 'SELECT activeTalentGroup FROM characters WHERE guid = ?', [guid], ) const active = Number(cg[0]?.activeTalentGroup ?? 0) const [talentRows] = await db(DB.characters).query( 'SELECT talentId, talentRank, talentGroup FROM character_talent WHERE guid = ?', [guid], ) const [glyphRows] = await db(DB.characters).query( 'SELECT talentGroup, glyphSlot, glyphId FROM character_glyphs WHERE guid = ?', [guid], ) const layout = talentLayout as Record const props = glyphProps as Record const classTabs = Object.entries(TALENT_TABS) .filter(([, v]) => v.classId === classId) .sort((a, b) => a[1].index - b[1].index) if (!classTabs.length) return { groups: [], activeIndex: 0, spellIds: [] } const allSpells = new Set() const raw: { group: number; view: TalentGroupView }[] = [] for (const grp of [0, 1]) { const rankById = new Map() for (const r of talentRows) { if (Number(r.talentGroup) === grp) rankById.set(Number(r.talentId), Number(r.talentRank) + 1) } let total = 0 let best = -1 let spec: string | null = null 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 }) allSpells.add(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 } }) if (total === 0) continue const major: GlyphInfo[] = [] const minor: GlyphInfo[] = [] for (const gr of glyphRows) { if (Number(gr.talentGroup) !== grp) continue const gid = Number(gr.glyphId) if (!gid) continue const p = props[String(gid)] if (!p) continue const info = await resolveSpellInfo(p.spell, locale) const g: GlyphInfo = { name: info?.name || `#${gid}`, icon: info?.icon || 'inv_misc_note_01' } if (Number(gr.glyphSlot) % 2 === 0) major.push(g) else minor.push(g) } raw.push({ group: grp, view: { spec: total > 0 ? spec : null, total, dist: trees.map((tr) => tr.points), trees, glyphs: { major, minor } }, }) } // La spec ACTIVA se muestra como Primaria (primera, por defecto). raw.sort((a, b) => (a.group === active ? 0 : 1) - (b.group === active ? 0 : 1)) const groups = raw.map((r) => r.view) return { groups, activeIndex: 0, spellIds: [...allSpells] } } catch { return { groups: [], activeIndex: 0, spellIds: [] } } } /** Contadores de misiones por estado (para el desplegable). */ export async function getQuestStatusCounts( guid: number, ): Promise<{ rewarded: number; complete: number; inprogress: number; failed: number }> { try { const [rew] = await db(DB.characters).query( 'SELECT COUNT(*) AS n FROM character_queststatus_rewarded WHERE guid = ?', [guid], ) const [prog] = await db(DB.characters).query( 'SELECT status, COUNT(*) AS n FROM character_queststatus WHERE guid = ? GROUP BY status', [guid], ) let complete = 0 let inprogress = 0 let failed = 0 for (const r of prog) { const st = Number(r.status) const n = Number(r.n) if (st === 1) complete += n else if (st === 5) failed += n else inprogress += n } return { rewarded: Number(rew[0]?.n ?? 0), complete, inprogress, failed } } catch { return { rewarded: 0, complete: 0, inprogress: 0, failed: 0 } } } // Rangos de reputación de WotLK: umbral (standing) + nombre + color de la barra. const REP_RANKS: { min: number; es: string; en: string; color: string }[] = [ { min: -42000, es: 'Odiado', en: 'Hated', color: '#c0392b' }, { min: -6000, es: 'Hostil', en: 'Hostile', color: '#c0392b' }, { min: -3000, es: 'Receloso', en: 'Unfriendly', color: '#cd6133' }, { min: 0, es: 'Neutral', en: 'Neutral', color: '#d9a300' }, { min: 3000, es: 'Amistoso', en: 'Friendly', color: '#4a9e2e' }, { min: 9000, es: 'Honorable', en: 'Honored', color: '#3ba55d' }, { min: 21000, es: 'Reverenciado', en: 'Revered', color: '#2e9d9d' }, { min: 42000, es: 'Exaltado', en: 'Exalted', color: '#9b59b6' }, ] export interface ReputationEntry { faction: number name: string standing: number rank: string rankIndex: number color: string cur: number max: number } /** Reputaciones visibles del personaje con rango y progreso. Nombres de Faction.db2. */ export async function getReputations(guid: number, locale: string): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT faction, standing, flags FROM character_reputation WHERE guid = ?', [guid], ) const names = factionNames as Record const out: ReputationEntry[] = [] for (const r of rows) { if (!(Number(r.flags) & 1)) continue // solo facciones visibles const fid = Number(r.faction) const info = names[String(fid)] if (!info) continue const name = (locale === 'en' ? info.en : info.es) || info.en if (!name) continue const standing = Number(r.standing) let ri = 0 for (let i = 0; i < REP_RANKS.length; i++) if (standing >= REP_RANKS[i].min) ri = i const rank = REP_RANKS[ri] const isExalted = ri === REP_RANKS.length - 1 const rankMax = isExalted ? rank.min + 1000 : REP_RANKS[ri + 1].min const cur = isExalted ? 1 : Math.max(0, standing - rank.min) const max = isExalted ? 1 : rankMax - rank.min out.push({ faction: fid, name, standing, rank: locale === 'en' ? rank.en : rank.es, rankIndex: ri, color: rank.color, cur, max, }) } out.sort((a, b) => b.rankIndex - a.rankIndex || b.standing - a.standing || a.name.localeCompare(b.name)) return out } catch { return [] } } export interface PvpRankEntry { rank: number guid: number name: string race: number class: number kills: number playedTime: number } /** Ranking mundial de bajas (Total Kills). Con búsqueda por nombre y paginación. */ export async function getPvpRank( page: number, pageSize: number, search: string, ): Promise<{ entries: PvpRankEntry[]; total: number }> { try { const s = (search || '').trim() const where = s ? 'WHERE name LIKE ?' : '' const nameParams = s ? [`%${s}%`] : [] const [cnt] = await db(DB.characters).query( `SELECT COUNT(*) AS n FROM characters ${where}`, nameParams, ) const total = Number(cnt[0]?.n ?? 0) const offset = Math.max(0, (page - 1) * pageSize) const [rows] = await db(DB.characters).query( `SELECT guid, name, race, class, totalKills, totaltime FROM characters ${where} ORDER BY totalKills DESC, guid ASC LIMIT ? OFFSET ?`, [...nameParams, pageSize, offset], ) const entries: PvpRankEntry[] = rows.map((r, i) => ({ rank: offset + i + 1, guid: Number(r.guid), name: r.name, race: Number(r.race), class: Number(r.class), kills: Number(r.totalKills), playedTime: Number(r.totaltime), })) return { entries, total } } catch { return { entries: [], total: 0 } } } export interface ArenaBracket { slot: number bracket: string personalRating: number mmr: number rank: number seasonGames: number seasonWins: number weekGames: number weekWins: number } /** Estadísticas de arena por bracket (2v2/3v3/5v5). 3.4.3 no usa equipos, es por personaje. */ export async function getCharacterArena(guid: number): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT slot, personalRating, matchMakerRating, `rank`, seasonGames, seasonWins, weekGames, weekWins FROM character_arena_stats WHERE guid = ?', [guid], ) const bySlot = new Map() for (const r of rows) bySlot.set(Number(r.slot), r) const brackets = [ { slot: 0, name: '2v2' }, { slot: 1, name: '3v3' }, { slot: 2, name: '5v5' }, ] return brackets.map((b) => { const r = bySlot.get(b.slot) return { slot: b.slot, bracket: b.name, personalRating: Number(r?.personalRating ?? 0), mmr: Number(r?.matchMakerRating ?? 0), rank: Number(r?.rank ?? 0), seasonGames: Number(r?.seasonGames ?? 0), seasonWins: Number(r?.seasonWins ?? 0), weekGames: Number(r?.weekGames ?? 0), weekWins: Number(r?.weekWins ?? 0), } }) } catch { return [] } } /** Puntos de honor (moneda 1901) y de arena (1900) del personaje. */ export async function getPvpCurrency(guid: number): Promise<{ honor: number; arena: number }> { try { const [rows] = await db(DB.characters).query( 'SELECT Currency, Quantity FROM character_currency WHERE CharacterGuid = ? AND Currency IN (1900, 1901)', [guid], ) let honor = 0 let arena = 0 for (const r of rows) { if (Number(r.Currency) === 1901) honor = Number(r.Quantity) else if (Number(r.Currency) === 1900) arena = Number(r.Quantity) } return { honor, arena } } catch { return { honor: 0, arena: 0 } } } export interface BgMatch { id: number type: number date: string won: boolean killingBlows: number deaths: number honorableKills: number bonusHonor: number damage: number healing: number } export interface BgHistory { matches: number wins: number losses: number winRate: number totalKb: number totalHk: number rows: BgMatch[] } /** * Historial de campos de batalla del personaje (pvpstats_battlegrounds + pvpstats_players). * Estas tablas las rellena el core al terminar cada BG si * Battleground.StoreStatistics.Enable = 1. `winner` es bit(1): 1 = su bando ganó. */ export async function getBattlegroundHistory(guid: number): Promise { const empty: BgHistory = { matches: 0, wins: 0, losses: 0, winRate: 0, totalKb: 0, totalHk: 0, rows: [] } try { const [rows] = await db(DB.characters).query( `SELECT b.id, b.type, b.date, p.winner, p.score_killing_blows AS kb, p.score_deaths AS deaths, p.score_honorable_kills AS hk, p.score_bonus_honor AS honor, p.score_damage_done AS dmg, p.score_healing_done AS heal FROM pvpstats_players p JOIN pvpstats_battlegrounds b ON b.id = p.battleground_id WHERE p.character_guid = ? ORDER BY b.date DESC LIMIT 100`, [guid], ) const toBool = (v: unknown): boolean => { if (Buffer.isBuffer(v)) return v[0] === 1 return Number(v) === 1 } const list: BgMatch[] = rows.map((r) => ({ id: Number(r.id), type: Number(r.type), date: r.date instanceof Date ? r.date.toISOString() : String(r.date), won: toBool(r.winner), killingBlows: Number(r.kb) || 0, deaths: Number(r.deaths) || 0, honorableKills: Number(r.hk) || 0, bonusHonor: Number(r.honor) || 0, damage: Number(r.dmg) || 0, healing: Number(r.heal) || 0, })) const wins = list.filter((m) => m.won).length const totalKb = list.reduce((s, m) => s + m.killingBlows, 0) const totalHk = list.reduce((s, m) => s + m.honorableKills, 0) return { matches: list.length, wins, losses: list.length - wins, winRate: list.length ? Math.round((wins / list.length) * 100) : 0, totalKb, totalHk, rows: list, } } catch { return empty } } // ---- Logros por categoría (estilo armería): rejilla + detalle ---- interface AchStatic { c: number; p: number; f: number; o: number; te: string; tn: string; de: string; dn: string; re?: string; rn?: string } const ACH_DATA = achievementsData as unknown as { categories: { id: number; es: string; en: string; o: number }[] ach: Record } export interface AchCategoryOverview { id: number name: string order: number total: number completed: number totalPoints: number earnedPoints: number pct: number isFeats: boolean } export interface AchOverview { categories: AchCategoryOverview[] earnedPoints: number totalCompleted: number totalPoints: number } /** Resumen de logros por categoría top-level, filtrado por facción del personaje. */ export async function getAchievementOverview( guid: number, faction: 'alliance' | 'horde' | null, locale: string, ): Promise { const facNum = faction === 'horde' ? 0 : faction === 'alliance' ? 1 : -2 const completed = new Set() try { const [rows] = await db(DB.characters).query( 'SELECT achievement FROM character_achievement WHERE guid = ?', [guid], ) for (const r of rows) completed.add(Number(r.achievement)) } catch { /* sin datos */ } const avail = (f: number) => f === -1 || facNum === -2 || f === facNum const cats: AchCategoryOverview[] = ACH_DATA.categories.map((c) => ({ id: c.id, name: locale === 'en' ? c.en : c.es, order: c.o, total: 0, completed: 0, totalPoints: 0, earnedPoints: 0, pct: 0, isFeats: false, })) const byId = new Map(cats.map((c) => [c.id, c])) let earnedPoints = 0 let totalCompleted = 0 let totalPoints = 0 for (const [idStr, a] of Object.entries(ACH_DATA.ach)) { if (!avail(a.f)) continue const cat = byId.get(a.c) if (!cat) continue cat.total++ cat.totalPoints += a.p totalPoints += a.p if (completed.has(Number(idStr))) { cat.completed++ cat.earnedPoints += a.p earnedPoints += a.p totalCompleted++ } } for (const c of cats) { c.isFeats = c.totalPoints === 0 c.pct = c.total ? Math.round((c.completed / c.total) * 100) : 0 } cats.sort((a, b) => a.order - b.order) return { categories: cats, earnedPoints, totalCompleted, totalPoints } } export interface AchDetail { id: number name: string description: string reward: string icon: string points: number date: number } /** Logros conseguidos por el personaje dentro de una categoría top-level (paginado). */ export async function getAchievementCategory( guid: number, categoryId: number, locale: string, page: number, pageSize: number, ): Promise<{ entries: AchDetail[]; total: number }> { try { const [rows] = await db(DB.characters).query( 'SELECT achievement, date FROM character_achievement WHERE guid = ? ORDER BY date DESC', [guid], ) const items = rows .map((r) => ({ id: Number(r.achievement), date: Number(r.date) })) .filter((it) => ACH_DATA.ach[String(it.id)]?.c === categoryId) const total = items.length const start = Math.max(0, (page - 1) * pageSize) const pageItems = items.slice(start, start + pageSize) const entries: AchDetail[] = 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 s = ACH_DATA.ach[String(it.id)] const info = await resolveAchievement(it.id, locale) entries[i + j] = { id: it.id, name: (locale === 'en' ? s?.tn : s?.te) || info?.name || `#${it.id}`, description: (locale === 'en' ? s?.dn : s?.de) || '', reward: (locale === 'en' ? s?.rn : s?.re) || '', icon: info?.icon || 'achievement_boss_generic', points: s?.p ?? 0, date: it.date, } }), ) } return { entries, total } } catch { return { entries: [], total: 0 } } } // ---- Progreso de bandas (Bandas): bitmask de jefes por dificultad ---- interface RaidStatic { map: number es: string en: string exp: number diffs: { id: number; es: string; en: string }[] bosses: { bit: number; es: string; en: string }[] } const RAIDS_DATA = raidsData as unknown as { raids: RaidStatic[] } export interface RaidBossState { name: string; killed: boolean } export interface RaidDifficulty { diff: number label: string killed: number total: number bosses: RaidBossState[] } export interface RaidProgress { map: number name: string exp: number total: number difficulties: RaidDifficulty[] } /** Progreso de bandas del personaje: jefes muertos por dificultad (bitmask real). */ export async function getRaidProgress(guid: number, locale: string): Promise { const masks = new Map() try { const [rows] = await db(DB.characters).query( 'SELECT map, diff, killed_mask FROM character_raid_progress WHERE guid = ?', [guid], ) for (const r of rows) masks.set(`${Number(r.map)}:${Number(r.diff)}`, Number(r.killed_mask)) } catch { /* tabla ausente o sin datos */ } const loc = locale === 'en' ? 'en' : 'es' return RAIDS_DATA.raids.map((r) => { const bosses = r.bosses.map((b) => ({ bit: b.bit, name: loc === 'en' ? b.en : b.es })) const total = bosses.length const difficulties = r.diffs.map((d) => { const mask = masks.get(`${r.map}:${d.id}`) || 0 const bstates = bosses.map((b) => ({ name: b.name, killed: (mask & (1 << b.bit)) !== 0 })) return { diff: d.id, label: loc === 'en' ? d.en : d.es, killed: bstates.filter((b) => b.killed).length, total, bosses: bstates, } }) return { map: r.map, name: loc === 'en' ? r.en : r.es, exp: r.exp, total, difficulties } }) } // ---- Progreso de mazmorras (Normal + Heroico), mismo modelo que bandas ---- const DUNGEONS_DATA = dungeonsData as unknown as { dungeons: RaidStatic[] } /** Progreso de mazmorras del personaje: jefes muertos por dificultad (bitmask). */ export async function getDungeonProgress(guid: number, locale: string): Promise { const masks = new Map() try { const [rows] = await db(DB.characters).query( 'SELECT map, diff, killed_mask FROM character_dungeon_progress WHERE guid = ?', [guid], ) for (const r of rows) masks.set(`${Number(r.map)}:${Number(r.diff)}`, Number(r.killed_mask)) } catch { /* tabla ausente o sin datos */ } const loc = locale === 'en' ? 'en' : 'es' return DUNGEONS_DATA.dungeons.map((r) => { const bosses = r.bosses.map((b) => ({ bit: b.bit, name: loc === 'en' ? b.en : b.es })) const total = bosses.length const difficulties = r.diffs.map((d) => { const mask = masks.get(`${r.map}:${d.id}`) || 0 const bstates = bosses.map((b) => ({ name: b.name, killed: (mask & (1 << b.bit)) !== 0 })) return { diff: d.id, label: loc === 'en' ? d.en : d.es, killed: bstates.filter((b) => b.killed).length, total, bosses: bstates, } }) return { map: r.map, name: loc === 'en' ? r.en : r.es, exp: r.exp, total, difficulties } }) } // ---- Página de hermandad (estilo armería) ---- export interface GuildInfo { guildid: number name: string leaderName: string | null leaderGuid: number emblem: { style: number; color: number; border: number; borderColor: number; bg: number } createdYear: number memberCount: number guildPoints: number motd: string info: string classBreakdown: { class: number; count: number }[] } /** Nombre del reino (realmlist). */ export async function getRealmName(): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT name FROM acore_auth.realmlist ORDER BY id LIMIT 1', ) return String(rows[0]?.name ?? '') } catch { return '' } } /** Cabecera + agregados de una hermandad. */ export async function getGuildInfo(guildid: number): Promise { try { const [g] = await db(DB.characters).query( `SELECT guildid, name, leaderguid, EmblemStyle, EmblemColor, BorderStyle, BorderColor, BackgroundColor, motd, info, FROM_UNIXTIME(createdate, '%Y') AS anio FROM guild WHERE guildid = ?`, [guildid], ) if (!g[0]) return null const [[cnt]] = await db(DB.characters).query( 'SELECT COUNT(*) AS n FROM guild_member WHERE guildid = ?', [guildid], ) as unknown as [RowDataPacket[]] const [[ln]] = await db(DB.characters).query( 'SELECT name FROM characters WHERE guid = ?', [Number(g[0].leaderguid)], ) as unknown as [RowDataPacket[]] const [[gp]] = await db(DB.characters).query( `SELECT COALESCE(SUM(ap.points),0) AS pts FROM (SELECT DISTINCT ca.achievement AS achievement FROM guild_member gm JOIN character_achievement ca ON ca.guid = gm.guid WHERE gm.guildid = ?) u JOIN acore_world.achievement_points ap ON ap.id = u.achievement`, [guildid], ) as unknown as [RowDataPacket[]] const [cb] = await db(DB.characters).query( `SELECT c.class AS class, COUNT(*) AS count FROM guild_member gm JOIN characters c ON c.guid = gm.guid WHERE gm.guildid = ? GROUP BY c.class ORDER BY count DESC`, [guildid], ) return { guildid: Number(g[0].guildid), name: String(g[0].name), leaderGuid: Number(g[0].leaderguid), leaderName: ln ? String(ln.name) : null, emblem: { style: Number(g[0].EmblemStyle), color: Number(g[0].EmblemColor), border: Number(g[0].BorderStyle), borderColor: Number(g[0].BorderColor), bg: Number(g[0].BackgroundColor), }, createdYear: Number(g[0].anio) || 0, memberCount: Number(cnt?.n ?? 0), guildPoints: Number(gp?.pts ?? 0), motd: String(g[0].motd || ''), info: String(g[0].info || ''), classBreakdown: cb.map((r) => ({ class: Number(r.class), count: Number(r.count) })), } } catch { return null } } export interface GuildRosterMember { guid: number name: string race: number class: number gender: number level: number rank: number rankName: string achPoints: number role: string ilvl: number } // ---- Rol (función) por especialización activa dominante ---- const GUILD_ROLE_MAP: Record = { 1: ['dps', 'dps', 'tank'], // Guerrero: Armas, Furia, Protección 2: ['healer', 'tank', 'dps'],// Paladín: Sagrado, Protección, Reprensión 3: ['dps', 'dps', 'dps'], // Cazador 4: ['dps', 'dps', 'dps'], // Pícaro 5: ['healer', 'healer', 'dps'], // Sacerdote: Disciplina, Sagrado, Sombra 6: ['tank', 'dps', 'dps'], // Caballero de la Muerte: Sangre, Escarcha, Profano 7: ['dps', 'dps', 'healer'], // Chamán: Elemental, Mejora, Restauración 8: ['dps', 'dps', 'dps'], // Mago 9: ['dps', 'dps', 'dps'], // Brujo 11: ['dps', 'dps', 'healer'],// Druida: Equilibrio, Feral, Restauración } /** Rol (tank/healer/dps) de cada miembro por su árbol de talentos activo dominante. */ export async function getGuildMemberRoles(guildid: number): Promise> { const map = new Map() try { const [rows] = await db(DB.characters).query( `SELECT ct.guid, ct.talentId, ct.talentRank, c.class FROM guild_member gm JOIN characters c ON c.guid = gm.guid JOIN character_talent ct ON ct.guid = gm.guid AND ct.talentGroup = c.activeTalentGroup WHERE gm.guildid = ?`, [guildid], ) const layout = talentLayout as TalentLayout const perGuid = new Map }>() for (const r of rows) { const g = Number(r.guid) let e = perGuid.get(g) if (!e) { e = { cls: Number(r.class), tabs: new Map() }; perGuid.set(g, e) } const l = layout[String(Number(r.talentId))] if (!l) continue e.tabs.set(l.tab, (e.tabs.get(l.tab) ?? 0) + Number(r.talentRank) + 1) } for (const [g, e] of perGuid) { const classTabs = Object.entries(TALENT_TABS) .filter(([, v]) => v.classId === e.cls) .sort((a, b) => a[1].index - b[1].index) const dist = classTabs.map(([tab]) => e.tabs.get(Number(tab)) ?? 0) if (!dist.some((x) => x > 0)) continue let bi = 0 for (let i = 1; i < dist.length; i++) if (dist[i] > dist[bi]) bi = i const role = (GUILD_ROLE_MAP[e.cls] && GUILD_ROLE_MAP[e.cls][bi]) || 'dps' map.set(g, role) } } catch { /* sin talentos */ } return map } /** Desglose de funciones de la hermandad (para la gráfica). */ export async function getGuildRoleBreakdown(guildid: number): Promise<{ tank: number; healer: number; dps: number }> { const out = { tank: 0, healer: 0, dps: 0 } try { const roles = await getGuildMemberRoles(guildid) const [rows] = await db(DB.characters).query( 'SELECT guid FROM guild_member WHERE guildid = ?', [guildid], ) for (const r of rows) { const role = (roles.get(Number(r.guid)) || 'dps') as 'tank' | 'healer' | 'dps' out[role]++ } } catch { /* */ } return out } /** Roster de la hermandad con filtro por clase/función, orden y paginación. */ export async function getGuildRoster( guildid: number, opts: { page: number; pageSize: number; classes: number[]; roles: string[]; sort: string }, ): Promise<{ members: GuildRosterMember[]; total: number }> { try { const [rows] = await db(DB.characters).query( `SELECT c.guid, c.name, c.race, c.class, c.gender, c.level, gm.rank, COALESCE(gr.rname, '') AS rankName, COALESCE(SUM(ap.points),0) AS achPoints FROM guild_member gm JOIN characters c ON c.guid = gm.guid LEFT JOIN guild_rank gr ON gr.guildid = gm.guildid AND gr.rid = gm.rank LEFT JOIN character_achievement ca ON ca.guid = gm.guid LEFT JOIN acore_world.achievement_points ap ON ap.id = ca.achievement WHERE gm.guildid = ? GROUP BY c.guid, c.name, c.race, c.class, c.gender, c.level, gm.rank, gr.rname`, [guildid], ) const roleMap = await getGuildMemberRoles(guildid) const ilvlMap = opts.sort === 'ilvl' ? await getGuildMemberIlvl(guildid) : new Map() let members: GuildRosterMember[] = rows.map((m) => ({ guid: Number(m.guid), name: String(m.name), race: Number(m.race), class: Number(m.class), gender: Number(m.gender), level: Number(m.level), rank: Number(m.rank), rankName: String(m.rankName || ''), achPoints: Number(m.achPoints || 0), role: roleMap.get(Number(m.guid)) || 'dps', ilvl: ilvlMap.get(Number(m.guid)) || 0, })) if (opts.classes.length) members = members.filter((m) => opts.classes.includes(m.class)) if (opts.roles.length) members = members.filter((m) => opts.roles.includes(m.role)) const byName = (a: GuildRosterMember, b: GuildRosterMember) => a.name.localeCompare(b.name) if (opts.sort === 'points') members.sort((a, b) => b.achPoints - a.achPoints || byName(a, b)) else if (opts.sort === 'level') members.sort((a, b) => b.level - a.level || byName(a, b)) else if (opts.sort === 'name') members.sort(byName) else if (opts.sort === 'ilvl') members.sort((a, b) => b.ilvl - a.ilvl || byName(a, b)) else members.sort((a, b) => a.rank - b.rank || b.level - a.level || byName(a, b)) const total = members.length const start = Math.max(0, (opts.page - 1) * opts.pageSize) return { members: members.slice(start, start + opts.pageSize), total } } catch { return { members: [], total: 0 } } } /** Logros recientes de la hermandad (guild_achievement) con nombre/icono. */ export async function getGuildRecentAchievements( guildid: number, locale: string, limit = 5, ): Promise { try { const [rows] = await db(DB.characters).query( 'SELECT achievement, date FROM guild_achievement WHERE guildId = ? ORDER BY date DESC LIMIT ?', [guildid, limit], ) const items = rows.map((r) => ({ id: Number(r.achievement), date: Number(r.date) })) const out: ActivityEntry[] = new Array(items.length) await Promise.all(items.map(async (it, i) => { const info = await resolveAchievement(it.id, locale) out[i] = { id: it.id, name: info?.name || `#${it.id}`, icon: info?.icon || 'achievement_guildperk_everybodysfriend', date: it.date } })) return out } catch { return [] } } // ---- Logros de hermandad (agregado de miembros, mismo sistema de anillos) ---- /** Resumen de logros de la hermandad: un logro cuenta si CUALQUIER miembro lo tiene. */ export async function getGuildAchievementOverview( guildid: number, faction: 'alliance' | 'horde' | null, locale: string, ): Promise { const facNum = faction === 'horde' ? 0 : faction === 'alliance' ? 1 : -2 const completed = new Set() try { const [rows] = await db(DB.characters).query( `SELECT DISTINCT ca.achievement FROM guild_member gm JOIN character_achievement ca ON ca.guid = gm.guid WHERE gm.guildid = ?`, [guildid], ) for (const r of rows) completed.add(Number(r.achievement)) } catch { /* sin datos */ } const avail = (f: number) => f === -1 || facNum === -2 || f === facNum const cats: AchCategoryOverview[] = ACH_DATA.categories.map((c) => ({ id: c.id, name: locale === 'en' ? c.en : c.es, order: c.o, total: 0, completed: 0, totalPoints: 0, earnedPoints: 0, pct: 0, isFeats: false, })) const byId = new Map(cats.map((c) => [c.id, c])) let earnedPoints = 0, totalCompleted = 0, totalPoints = 0 for (const [idStr, a] of Object.entries(ACH_DATA.ach)) { if (!avail(a.f)) continue const cat = byId.get(a.c) if (!cat) continue cat.total++; cat.totalPoints += a.p; totalPoints += a.p if (completed.has(Number(idStr))) { cat.completed++; cat.earnedPoints += a.p; earnedPoints += a.p; totalCompleted++ } } for (const c of cats) { c.isFeats = c.totalPoints === 0 c.pct = c.total ? Math.round((c.completed / c.total) * 100) : 0 } cats.sort((a, b) => a.order - b.order) return { categories: cats, earnedPoints, totalCompleted, totalPoints } } /** Logros de una categoría conseguidos por la hermandad (cualquier miembro), paginado. */ export async function getGuildAchievementCategory( guildid: number, categoryId: number, locale: string, page: number, pageSize: number, ): Promise<{ entries: AchDetail[]; total: number }> { try { const [rows] = await db(DB.characters).query( `SELECT ca.achievement AS achievement, MIN(ca.date) AS date FROM guild_member gm JOIN character_achievement ca ON ca.guid = gm.guid WHERE gm.guildid = ? GROUP BY ca.achievement`, [guildid], ) const items = rows .map((r) => ({ id: Number(r.achievement), date: Number(r.date) })) .filter((it) => ACH_DATA.ach[String(it.id)]?.c === categoryId) .sort((a, b) => b.date - a.date) const total = items.length const start = Math.max(0, (page - 1) * pageSize) const pageItems = items.slice(start, start + pageSize) const entries: AchDetail[] = 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 s = ACH_DATA.ach[String(it.id)] const info = await resolveAchievement(it.id, locale) entries[i + j] = { id: it.id, name: (locale === 'en' ? s?.tn : s?.te) || info?.name || `#${it.id}`, description: (locale === 'en' ? s?.dn : s?.de) || '', reward: (locale === 'en' ? s?.rn : s?.re) || '', icon: info?.icon || 'achievement_boss_generic', points: s?.p ?? 0, date: it.date, } })) } return { entries, total } } catch { return { entries: [], total: 0 } } } /** Nivel de objeto medio del equipo de cada miembro (excluye camisa y tabardo). */ export async function getGuildMemberIlvl(guildid: number): Promise> { const map = new Map() try { const [rows] = await db(DB.characters).query( `SELECT ci.guid AS guid, AVG(d.item_level) AS ilvl FROM guild_member gm JOIN character_inventory ci ON ci.guid = gm.guid AND ci.bag = 0 AND ci.slot IN (0,1,2,4,5,6,7,8,9,10,11,12,13,14,15,16,17) JOIN item_instance ii ON ii.guid = ci.item JOIN django_wow.item_data d ON d.entry = ii.itemEntry WHERE gm.guildid = ? GROUP BY ci.guid`, [guildid], ) for (const r of rows) map.set(Number(r.guid), Math.round(Number(r.ilvl) || 0)) } catch { /* */ } return map } /** ¿Alguna char de la cuenta pertenece a la hermandad? (para el mensaje diario). */ export async function isGuildMemberByAccount(accountId: number, guildid: number): Promise { if (!accountId) return false try { const [rows] = await db(DB.characters).query( 'SELECT 1 FROM characters c JOIN guild_member gm ON gm.guid = c.guid WHERE c.account = ? AND gm.guildid = ? LIMIT 1', [accountId, guildid], ) return rows.length > 0 } catch { return false } }