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.
This commit is contained in:
2026-07-16 21:07:39 +00:00
parent 65f6776969
commit 3af7f5e804
18 changed files with 1788 additions and 105 deletions
+496 -3
View File
@@ -1,5 +1,9 @@
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/
@@ -32,6 +36,8 @@ export interface GuildResult {
export interface EquipItem {
slot: number
entry: number
ench: number
gems: number[]
name: string
quality: number
itemLevel: number
@@ -109,11 +115,11 @@ export async function suggest(
}
export async function getCharacter(guid: number): Promise<
(CharacterResult & { guildName: string | null; guildId: number | null; online: boolean }) | null
(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 FROM characters WHERE guid = ?',
'SELECT guid, name, race, gender, class, level, online, health, power1 FROM characters WHERE guid = ?',
[guid],
)
const c = rows[0]
@@ -140,6 +146,8 @@ export async function getCharacter(guid: number): Promise<
class: c.class,
level: c.level,
online: c.online === 1,
health: Number(c.health) || 0,
power: Number(c.power1) || 0,
guildName,
guildId,
}
@@ -153,7 +161,7 @@ export async function getCharacterEquipment(guid: number, locale: string): Promi
const col = locale === 'en' ? 'name_en' : 'name_es'
try {
const [inv] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT ci.slot, ii.itemEntry, ii.transmogrification
`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],
@@ -179,9 +187,12 @@ export async function getCharacterEquipment(guid: number, locale: string): Promi
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,
@@ -234,3 +245,485 @@ export async function getGuild(
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 }
}
}