Files
NightSpire/web-next/lib/armory.ts
T
Inna beba7301a1 Armería: PvP campos de batalla, logros por categoría, bandas/mazmorras y página de hermandad
- PvP: pestaña Campos de batalla (pvpstats) con resultado, stats por partida y fecha/hora.
- Logros: rejilla por categoría con anillos de progreso + detalle por categoría
  (datos de Achievement.db2 + Achievement_Category.db2, iconos vía wowhead).
- Bandas: progreso por dificultad (10/25/heroico) con jefes reales de DungeonEncounter.db2
  y tooltip de jefes; agrupado por expansión (Clásico/TBC/WotLK).
- Mazmorras: mismo sistema, Normal + Heroico, Clásico/TBC/WotLK.
- Página de hermandad estilo armería: cabecera (emblema, puntos = unión de logros de
  miembros sin duplicar, nº miembros, fundada), pestañas Hermandad/Logros, roster con
  iconos de clase/raza, columna Función (rol por spec), filtros clase/función,
  orden por rango/puntos/nivel de objeto/nivel/nombre, paginación.
- Barra lateral: mensaje diario con login (iron-session, gated por pertenencia),
  logros recientes y desgloses de clases y funciones.
- Cabecera de personaje: enlace a la hermandad + nombre del reino.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:18:30 +00:00

1920 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CharacterResult[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<GuildResult[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`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<ItemResult[]> {
const col = locale === 'en' ? 'name_en' : 'name_es'
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
`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<RowDataPacket[]>(
'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<RowDataPacket[]>(
`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<EquipItem[]> {
const col = locale === 'en' ? 'name_en' : 'name_es'
try {
const [inv] = await db(DB.characters).query<RowDataPacket[]>(
`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<number, RowDataPacket>()
if (entries.length) {
const [items] = await db(DB.default).query<RowDataPacket[]>(
`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<RowDataPacket[]>(
'SELECT guildid, name, leaderguid FROM guild WHERE guildid = ?',
[guildid],
)
if (!g[0]) return null
const [members] = await db(DB.characters).query<RowDataPacket[]>(
`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<CharacterCustomization[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<CharacterStats | null> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<Profession[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<string> {
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[^>]*>([^<]+)<\/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<Record<number, string>> {
const uniq = [...new Set(entries.filter((e) => e > 0))]
const out: Record<number, string> = {}
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<number> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
`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<string, { tab: number; tier: number; col: number; max: number; spell: number }>
/** Árbol de talentos completo (character_talent + Talent.db2 layout). */
export async function getCharacterTalents(guid: number, classId: number, locale: string): Promise<TalentSummary | null> {
try {
const [cg] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT activeTalentGroup FROM characters WHERE guid = ?',
[guid],
)
const group = Number(cg[0]?.activeTalentGroup ?? 0)
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT talentId, talentRank FROM character_talent WHERE guid = ? AND talentGroup = ?',
[guid, group],
)
const rankById = new Map<number, number>()
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<string> {
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<Record<number, string>> {
const uniq = [...new Set(spells.filter((s) => s > 0))]
const out: Record<number, string> = {}
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<RowDataPacket[]>(
'SELECT activeTalentGroup FROM characters WHERE guid = ?',
[guid],
)
const active = Number(cg[0]?.activeTalentGroup ?? 0)
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<number, number>()
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<string, string> = { 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<number> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<ActivityEntry[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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<number>()
if (ids.length) {
const [nrows] = await db(DB.default).query<RowDataPacket[]>(
`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<number> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
'SELECT activeTalentGroup FROM characters WHERE guid = ?',
[guid],
)
const group = Number(cg[0]?.activeTalentGroup ?? 0)
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT glyphSlot, glyphId FROM character_glyphs WHERE guid = ? AND talentGroup = ? ORDER BY glyphSlot',
[guid, group],
)
const props = glyphProps as Record<string, { spell: number; type: number }>
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<string> {
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<QuestEntry[]> {
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<RowDataPacket[]>(
'SELECT quest FROM character_queststatus_rewarded WHERE guid = ?',
[guid],
)
const [prog] = await db(DB.characters).query<RowDataPacket[]>(
'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<CollectionEntry[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
'SELECT activeTalentGroup FROM characters WHERE guid = ?',
[guid],
)
const active = Number(cg[0]?.activeTalentGroup ?? 0)
const [talentRows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT talentId, talentRank, talentGroup FROM character_talent WHERE guid = ?',
[guid],
)
const [glyphRows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT talentGroup, glyphSlot, glyphId FROM character_glyphs WHERE guid = ?',
[guid],
)
const layout = talentLayout as Record<string, { tab: number; tier: number; col: number; max: number; spell: number }>
const props = glyphProps as Record<string, { spell: number; type: number }>
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<number>()
const raw: { group: number; view: TalentGroupView }[] = []
for (const grp of [0, 1]) {
const rankById = new Map<number, number>()
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<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM character_queststatus_rewarded WHERE guid = ?',
[guid],
)
const [prog] = await db(DB.characters).query<RowDataPacket[]>(
'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<ReputationEntry[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT faction, standing, flags FROM character_reputation WHERE guid = ?',
[guid],
)
const names = factionNames as Record<string, { es: string; en: string }>
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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<ArenaBracket[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT slot, personalRating, matchMakerRating, `rank`, seasonGames, seasonWins, weekGames, weekWins FROM character_arena_stats WHERE guid = ?',
[guid],
)
const bySlot = new Map<number, RowDataPacket>()
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<RowDataPacket[]>(
'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<BgHistory> {
const empty: BgHistory = { matches: 0, wins: 0, losses: 0, winRate: 0, totalKb: 0, totalHk: 0, rows: [] }
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`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<string, AchStatic>
}
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<AchOverview> {
const facNum = faction === 'horde' ? 0 : faction === 'alliance' ? 1 : -2
const completed = new Set<number>()
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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<RaidProgress[]> {
const masks = new Map<string, number>()
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<RaidProgress[]> {
const masks = new Map<string, number>()
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<string> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<GuildInfo | null> {
try {
const [g] = await db(DB.characters).query<RowDataPacket[]>(
`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<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM guild_member WHERE guildid = ?',
[guildid],
) as unknown as [RowDataPacket[]]
const [[ln]] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT name FROM characters WHERE guid = ?',
[Number(g[0].leaderguid)],
) as unknown as [RowDataPacket[]]
const [[gp]] = await db(DB.characters).query<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<number, string[]> = {
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<Map<number, string>> {
const map = new Map<number, string>()
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`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<number, { cls: number; tabs: Map<number, number> }>()
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<RowDataPacket[]>(
'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<RowDataPacket[]>(
`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<number, number>()
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<ActivityEntry[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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<AchOverview> {
const facNum = faction === 'horde' ? 0 : faction === 'alliance' ? 1 : -2
const completed = new Set<number>()
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<Map<number, number>> {
const map = new Map<number, number>()
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`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<boolean> {
if (!accountId) return false
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'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
}
}