Files
NightSpire/web-next/lib/armory.ts
T
Inna 297257b753 Armería/Armory: buscador, ficha de personaje con equipo y visor 3D
Nueva sección Armería (menú Jugadores del header, pública). Migra la idea del
módulo armory de FusionCMS a Next, ajustada al diseño del sitio y en ES/EN.

DATOS (verificado antes de construir): item_template no existe en 3.4.3, pero los
datos de ítem ya están en MySQL (django_wow.item_data, 44.873 filas con nombre
es/en, calidad, item_level, inventory_type y display_id). El equipo del personaje
sale de acore_characters (character_inventory + item_instance, con transmog) y se
resuelve contra item_data en dos pasos (sin joins entre esquemas). lib/armory.ts +
lib/wow-data.ts (razas/clases/slots ES/EN y colores de clase).

RUTAS:
- /armory: buscador con pestañas personajes/ítems/hermandades (SSR por searchParams),
  ítems con tooltip/color/icono de wowhead.
- /armory/character/[guid]: identidad, equipo (tooltips wowhead, color por calidad)
  y visor 3D.
- /armory/guild/[guid]: miembros con enlace a su ficha.

VISOR 3D (components/ArmoryModel3D): usa la librería wow-model-viewer (generateModels)
con jQuery + ZamModelViewer de wowhead cargados bajo demanda, alimentado con
raza+género+display_ids del equipo (transmog si lo hay). Es integración cliente que
depende del CDN de zamimg y NO se puede verificar sin navegador: va todo en try/catch
con temporizador de reserva y aviso si no carga, para no romper la ficha.

Verificado en producción (salvo el render 3D): buscador, ficha con equipo real
(«Camisa de acólito»/«Acolyte's Shirt»), tooltips de wowhead y página de hermandad,
en ES y EN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:29:02 +00:00

224 lines
6.7 KiB
TypeScript

import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
// 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
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 name LIKE ? ORDER BY level DESC, name LIMIT ?',
[`%${q}%`, 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 g.name LIKE ? ORDER BY members DESC LIMIT ?`,
[`%${q}%`, 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 ${col} LIKE ? OR name_en LIKE ? ORDER BY item_level DESC LIMIT ?`,
[`%${q}%`, `%${q}%`, limit],
)
return rows.map((r) => ({
entry: r.entry,
name: r.name,
quality: r.quality,
itemLevel: r.item_level,
inventoryType: r.inventory_type,
}))
} catch {
return []
}
}
export async function getCharacter(guid: number): Promise<
(CharacterResult & { guildName: string | null; guildId: number | null; online: boolean }) | null
> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guid, name, race, gender, class, level, online 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,
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
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
return {
slot: r.slot,
entry,
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
}
}