Files
NightSpire/web-next/lib/armory.ts
T
Inna 3af7f5e804 Armería: muñeco de papel, talentos, actividad y caché del visor 3D
La ficha de personaje pasa a mostrar el equipo sobre el paperdoll (con
encantamientos y gemas en los tooltips de wowhead), el árbol de talentos y
la actividad reciente del personaje.

El proxy de /modelviewer/* deja de ser un rewrite de Next y pasa a un route
handler que cachea en disco los assets de zamimg, para no repetir la descarga
en cada visita.
2026-07-16 21:07:39 +00:00

729 lines
24 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'
// 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 }) | null
> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guid, name, race, gender, class, level, online, health, power1 FROM characters WHERE guid = ?',
[guid],
)
const c = rows[0]
if (!c) return null
let guildName: string | null = null
let guildId: number | null = null
try {
const [g] = await db(DB.characters).query<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,
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
}
// 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) })
}
}
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,
q: 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 query = (q || '').trim()
if (query) {
const idMatch = /^\d+$/.test(query) ? Number(query) : null
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(',')})`,
[`%${query}%`, ...ids],
)
for (const r of nrows) nameIds.add(Number(r.id))
}
items = items.filter((it) => nameIds.has(it.id) || (idMatch !== null && it.id === idMatch))
}
const total = items.length
const start = Math.max(0, (page - 1) * pageSize)
const pageItems = items.slice(start, start + pageSize)
const entries: ActivityEntry[] = new Array(pageItems.length)
for (let i = 0; i < pageItems.length; i += 8) {
const chunk = pageItems.slice(i, i + 8)
await Promise.all(
chunk.map(async (it, j) => {
const info = await resolveAchievement(it.id, locale)
entries[i + j] = {
id: it.id,
name: info?.name || `#${it.id}`,
icon: info?.icon || 'achievement_boss_generic',
date: it.date,
}
}),
)
}
return { entries, total }
} catch {
return { entries: [], total: 0 }
}
}