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:
+496
-3
@@ -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 }
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -65,3 +65,100 @@ export function classColor(id: number): string {
|
||||
export function factionOf(race: number): 'alliance' | 'horde' | null {
|
||||
return RACES[race]?.faction ?? null
|
||||
}
|
||||
|
||||
// Mapa slot -> etiqueta y layout tipo "paperdoll" de la ficha (columnas izq/der + armas).
|
||||
const SLOT_LABELS = new Map(EQUIP_SLOTS.map((s) => [s.slot, s]))
|
||||
export function slotLabel(slot: number, locale: string): string {
|
||||
const s = SLOT_LABELS.get(slot)
|
||||
return s ? (locale === 'en' ? s.en : s.es) : ''
|
||||
}
|
||||
export const PAPERDOLL_LEFT = [0, 1, 2, 14, 4, 3, 18, 8]
|
||||
export const PAPERDOLL_RIGHT = [9, 5, 6, 7, 10, 11, 12, 13]
|
||||
export const PAPERDOLL_WEAPONS = [15, 16, 17]
|
||||
|
||||
// Skill lines de profesiones (primarias + secundarias) de WotLK. IDs estándar.
|
||||
export const PROFESSION_SKILLS: Record<number, { es: string; en: string }> = {
|
||||
171: { es: 'Alquimia', en: 'Alchemy' },
|
||||
164: { es: 'Herrería', en: 'Blacksmithing' },
|
||||
333: { es: 'Encantamiento', en: 'Enchanting' },
|
||||
202: { es: 'Ingeniería', en: 'Engineering' },
|
||||
182: { es: 'Herboristería', en: 'Herbalism' },
|
||||
773: { es: 'Inscripción', en: 'Inscription' },
|
||||
755: { es: 'Joyería', en: 'Jewelcrafting' },
|
||||
165: { es: 'Peletería', en: 'Leatherworking' },
|
||||
186: { es: 'Minería', en: 'Mining' },
|
||||
393: { es: 'Desuello', en: 'Skinning' },
|
||||
197: { es: 'Sastrería', en: 'Tailoring' },
|
||||
129: { es: 'Primeros auxilios', en: 'First Aid' },
|
||||
185: { es: 'Cocina', en: 'Cooking' },
|
||||
356: { es: 'Pesca', en: 'Fishing' },
|
||||
762: { es: 'Equitación', en: 'Riding' },
|
||||
}
|
||||
|
||||
// Pestañas de talento de WotLK: tabId -> { clase, índice de árbol 0/1/2, nombre }.
|
||||
export const TALENT_TABS: Record<number, { classId: number; index: number; es: string; en: string }> = {
|
||||
161: { classId: 1, index: 0, es: 'Armas', en: 'Arms' },
|
||||
164: { classId: 1, index: 1, es: 'Furia', en: 'Fury' },
|
||||
163: { classId: 1, index: 2, es: 'Protección', en: 'Protection' },
|
||||
382: { classId: 2, index: 0, es: 'Sagrado', en: 'Holy' },
|
||||
383: { classId: 2, index: 1, es: 'Protección', en: 'Protection' },
|
||||
381: { classId: 2, index: 2, es: 'Reprensión', en: 'Retribution' },
|
||||
361: { classId: 3, index: 0, es: 'Bestias', en: 'Beast Mastery' },
|
||||
363: { classId: 3, index: 1, es: 'Puntería', en: 'Marksmanship' },
|
||||
362: { classId: 3, index: 2, es: 'Supervivencia', en: 'Survival' },
|
||||
182: { classId: 4, index: 0, es: 'Asesinato', en: 'Assassination' },
|
||||
181: { classId: 4, index: 1, es: 'Combate', en: 'Combat' },
|
||||
183: { classId: 4, index: 2, es: 'Sutileza', en: 'Subtlety' },
|
||||
201: { classId: 5, index: 0, es: 'Disciplina', en: 'Discipline' },
|
||||
202: { classId: 5, index: 1, es: 'Sagrado', en: 'Holy' },
|
||||
203: { classId: 5, index: 2, es: 'Sombras', en: 'Shadow' },
|
||||
398: { classId: 6, index: 0, es: 'Sangre', en: 'Blood' },
|
||||
399: { classId: 6, index: 1, es: 'Escarcha', en: 'Frost' },
|
||||
400: { classId: 6, index: 2, es: 'Profano', en: 'Unholy' },
|
||||
261: { classId: 7, index: 0, es: 'Elemental', en: 'Elemental' },
|
||||
263: { classId: 7, index: 1, es: 'Mejora', en: 'Enhancement' },
|
||||
262: { classId: 7, index: 2, es: 'Restauración', en: 'Restoration' },
|
||||
81: { classId: 8, index: 0, es: 'Arcano', en: 'Arcane' },
|
||||
41: { classId: 8, index: 1, es: 'Fuego', en: 'Fire' },
|
||||
61: { classId: 8, index: 2, es: 'Escarcha', en: 'Frost' },
|
||||
302: { classId: 9, index: 0, es: 'Aflicción', en: 'Affliction' },
|
||||
303: { classId: 9, index: 1, es: 'Demonología', en: 'Demonology' },
|
||||
301: { classId: 9, index: 2, es: 'Destrucción', en: 'Destruction' },
|
||||
283: { classId: 11, index: 0, es: 'Equilibrio', en: 'Balance' },
|
||||
281: { classId: 11, index: 1, es: 'Feral', en: 'Feral Combat' },
|
||||
282: { classId: 11, index: 2, es: 'Restauración', en: 'Restoration' },
|
||||
}
|
||||
|
||||
// Recurso primario por clase (WotLK): etiqueta + color. Guerrero=Ira, Pícaro=Energía,
|
||||
// Caballero de la Muerte=Poder rúnico, resto=Maná. El valor sale de characters.power1.
|
||||
export const CLASS_POWER: Record<number, { es: string; en: string; color: string }> = {
|
||||
1: { es: 'Ira', en: 'Rage', color: '#c8412b' },
|
||||
2: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
3: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
4: { es: 'Energía', en: 'Energy', color: '#e6cc00' },
|
||||
5: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
6: { es: 'Poder rúnico', en: 'Runic Power', color: '#00b0d8' },
|
||||
7: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
8: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
9: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
11: { es: 'Maná', en: 'Mana', color: '#4a90d9' },
|
||||
}
|
||||
|
||||
export function classPower(classId: number, locale: string): { label: string; color: string } {
|
||||
const p = CLASS_POWER[classId] ?? CLASS_POWER[2]
|
||||
return { label: locale === 'en' ? p.en : p.es, color: p.color }
|
||||
}
|
||||
|
||||
// Icono representativo de cada pestaña de talento (spec) de WotLK, por tabId.
|
||||
export const SPEC_ICONS: Record<number, string> = {
|
||||
161: 'ability_warrior_savageblow', 164: 'ability_warrior_innerrage', 163: 'ability_warrior_defensivestance',
|
||||
382: 'spell_holy_holybolt', 383: 'spell_holy_devotionaura', 381: 'spell_holy_auraoflight',
|
||||
361: 'ability_hunter_beasttaming', 363: 'ability_marksmanship', 362: 'ability_hunter_swiftstrike',
|
||||
182: 'ability_rogue_eviscerate', 181: 'ability_backstab', 183: 'ability_stealth',
|
||||
201: 'spell_holy_wordfortitude', 202: 'spell_holy_guardianspirit', 203: 'spell_shadow_shadowwordpain',
|
||||
398: 'spell_deathknight_bloodpresence', 399: 'spell_deathknight_frostpresence', 400: 'spell_deathknight_unholypresence',
|
||||
261: 'spell_nature_lightning', 263: 'spell_nature_lightningshield', 262: 'spell_nature_magicimmunity',
|
||||
81: 'spell_holy_magicalsentry', 41: 'spell_fire_firebolt02', 61: 'spell_frost_frostbolt02',
|
||||
302: 'spell_shadow_deathcoil', 303: 'spell_shadow_metamorphosis', 301: 'spell_shadow_rainoffire',
|
||||
283: 'spell_nature_starfall', 281: 'ability_druid_catform', 282: 'spell_nature_healingtouch',
|
||||
}
|
||||
|
||||
+10
-2
@@ -59,6 +59,14 @@ function wowheadDomain(locale: string): string {
|
||||
* el script prioriza este atributo sobre el subdominio del href; sin el idioma
|
||||
* aquí, el tooltip saldría en inglés aunque el enlace sea es.wowhead.com.
|
||||
*/
|
||||
export function wowheadData(type: WowheadType, id: number | string, locale: string): string {
|
||||
return `${type}=${id}&domain=${wowheadDomain(locale)}`
|
||||
export function wowheadData(
|
||||
type: WowheadType,
|
||||
id: number | string,
|
||||
locale: string,
|
||||
opts?: { ench?: number; gems?: number[] },
|
||||
): string {
|
||||
let s = `${type}=${id}&domain=${wowheadDomain(locale)}`
|
||||
if (opts?.ench) s += `&ench=${opts.ench}`
|
||||
if (opts?.gems && opts.gems.length) s += `&gems=${opts.gems.join(':')}`
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user