beba7301a1
- 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>
1920 lines
66 KiB
TypeScript
1920 lines
66 KiB
TypeScript
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
|
||
}
|
||
}
|