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:
@@ -10,3 +10,8 @@ home/migrations/__pycache__
|
||||
# Frontend (Vite / React islands)
|
||||
frontend/node_modules/
|
||||
static/dist/
|
||||
|
||||
# Caché en disco del proxy del visor de modelos (app/modelviewer) — assets de zamimg
|
||||
wmmv-cache/
|
||||
# Copias de seguridad locales
|
||||
web-next/.bak-armory/
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getCharacter, getCharacterEquipment } from '@/lib/armory'
|
||||
import { raceName, className, classColor, factionOf, EQUIP_SLOTS } from '@/lib/wow-data'
|
||||
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
import {
|
||||
getCharacter,
|
||||
getCharacterEquipment,
|
||||
getCharacterCustomizations,
|
||||
getCharacterStats,
|
||||
getCharacterProfessions,
|
||||
getItemIcons,
|
||||
getAchievementPoints,
|
||||
getCharacterTalents,
|
||||
getCharacterSpecs,
|
||||
getSpellIcons,
|
||||
getActivityPage,
|
||||
} from '@/lib/armory'
|
||||
import { raceName, className, classColor, factionOf, classPower } from '@/lib/wow-data'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ArmoryPaperdoll, type SheetItem } from '@/components/ArmoryPaperdoll'
|
||||
import { wowheadIcon } from '@/lib/wowhead'
|
||||
import { ArmoryTalents } from '@/components/ArmoryTalents'
|
||||
import { ArmoryActivity } from '@/components/ArmoryActivity'
|
||||
import { WowheadRefresh } from '@/components/WowheadRefresh'
|
||||
import { ArmoryModel3D } from '@/components/ArmoryModel3D'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -22,34 +36,76 @@ export default async function CharacterPage({
|
||||
const id = Number(guid)
|
||||
const character = await getCharacter(id)
|
||||
if (!character) notFound()
|
||||
const equipment = await getCharacterEquipment(id, locale)
|
||||
const bySlot = new Map(equipment.map((e) => [e.slot, e]))
|
||||
|
||||
const [equipment, customizations, stats, professions, achievementPoints, talents, specs, activityData] = await Promise.all([
|
||||
getCharacterEquipment(id, locale),
|
||||
getCharacterCustomizations(id),
|
||||
getCharacterStats(id),
|
||||
getCharacterProfessions(id, locale),
|
||||
getAchievementPoints(id),
|
||||
getCharacterTalents(id, character.class, locale),
|
||||
getCharacterSpecs(id, character.class, locale),
|
||||
getActivityPage(id, locale, 1, 10, ''),
|
||||
])
|
||||
const icons = await getItemIcons(equipment.map((e) => e.entry))
|
||||
const spellIcons = talents ? await getSpellIcons(talents.trees.flatMap((tr) => tr.talents.map((c) => c.spell))) : {}
|
||||
|
||||
const items: SheetItem[] = equipment.map((e) => ({
|
||||
slot: e.slot,
|
||||
entry: e.entry,
|
||||
name: e.name,
|
||||
quality: e.quality,
|
||||
itemLevel: e.itemLevel,
|
||||
inventoryType: e.inventoryType,
|
||||
displayId: e.displayId,
|
||||
transmogDisplayId: e.transmogDisplayId,
|
||||
ench: e.ench,
|
||||
gems: e.gems,
|
||||
icon: icons[e.entry] ?? null,
|
||||
}))
|
||||
|
||||
const gear = equipment.filter((e) => e.slot !== 3 && e.slot !== 18 && e.itemLevel > 0)
|
||||
const avgItemLevel = gear.length ? Math.round(gear.reduce((s, e) => s + e.itemLevel, 0) / gear.length) : 0
|
||||
const faction = factionOf(character.race)
|
||||
const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
|
||||
const power = classPower(character.class, locale)
|
||||
|
||||
// Modelo 3D: raza, género y equipo (entry + display; transmog si lo hay).
|
||||
const modelEquip = equipment
|
||||
.map((e) => ({
|
||||
slot: e.slot,
|
||||
entry: e.transmogEntry ?? e.entry,
|
||||
displayid: e.transmogDisplayId ?? e.displayId ?? 0,
|
||||
}))
|
||||
.filter((e) => e.displayid > 0)
|
||||
const statRows = stats
|
||||
? [
|
||||
{ label: t('strength'), value: `${stats.strength}` },
|
||||
{ label: t('agility'), value: `${stats.agility}` },
|
||||
{ label: t('stamina'), value: `${stats.stamina}` },
|
||||
{ label: t('intellect'), value: `${stats.intellect}` },
|
||||
{ label: t('armor'), value: `${stats.armor}` },
|
||||
{ label: t('attackPower'), value: `${stats.attackPower}` },
|
||||
{ label: t('rangedAttackPower'), value: `${stats.rangedAttackPower}` },
|
||||
{ label: t('spellPower'), value: `${stats.spellPower}` },
|
||||
{ label: t('critChance'), value: `${stats.critPct.toFixed(2)}%` },
|
||||
{ label: t('spellCrit'), value: `${stats.spellCritPct.toFixed(2)}%` },
|
||||
{ label: t('dodge'), value: `${stats.dodgePct.toFixed(2)}%` },
|
||||
{ label: t('parry'), value: `${stats.parryPct.toFixed(2)}%` },
|
||||
{ label: t('block'), value: `${stats.blockPct.toFixed(2)}%` },
|
||||
{ label: t('resilience'), value: `${stats.resilience}` },
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<PageShell title={character.name}>
|
||||
<div className="main-wide">
|
||||
<WowheadRefresh dep={`char-${id}`} />
|
||||
<p className="forum-path">
|
||||
<Link href="/armory">← {t('backToArmory')}</Link>
|
||||
</p>
|
||||
|
||||
<div className="armory-char">
|
||||
{/* Columna izquierda: identidad + modelo 3D */}
|
||||
<div className="armory-char-left">
|
||||
<div className="armory-sheet">
|
||||
<div className="armory-sheet-head">
|
||||
<h1 className="armory-char-name" style={{ color: classColor(character.class) }}>
|
||||
{character.name}
|
||||
{avgItemLevel > 0 ? (
|
||||
<span className="armory-char-ilvl">
|
||||
{' '}
|
||||
({t('itemLevel')} {avgItemLevel})
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<div className="armory-char-sub">
|
||||
{t('level')} {character.level} · {raceName(character.race, locale)} ·{' '}
|
||||
@@ -65,52 +121,133 @@ export default async function CharacterPage({
|
||||
<{character.guildName}>
|
||||
</Link>
|
||||
)}
|
||||
<span className="armory-achpoints" title={t('achievementPoints')}>
|
||||
<i className="fas fa-star" /> {achievementPoints.toLocaleString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
<ArmoryModel3D
|
||||
race={character.race}
|
||||
gender={character.gender}
|
||||
equip={modelEquip}
|
||||
labels={{ loading: t('modelLoading'), unavailable: t('modelUnavailable') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Columna derecha: equipo */}
|
||||
<div className="armory-char-right">
|
||||
<h2 className="armory-section-title">{t('equipment')}</h2>
|
||||
{equipment.length === 0 ? (
|
||||
<p className="armory-hint">{t('noEquipment')}</p>
|
||||
) : (
|
||||
<div className="armory-equip">
|
||||
{EQUIP_SLOTS.map(({ slot, es, en }) => {
|
||||
const item = bySlot.get(slot)
|
||||
const slotName = locale === 'en' ? en : es
|
||||
if (!item) {
|
||||
return (
|
||||
<div key={slot} className="armory-equip-slot empty">
|
||||
<span className="armory-slot-name">{slotName}</span>
|
||||
<ArmoryPaperdoll
|
||||
race={character.race}
|
||||
gender={character.gender}
|
||||
customizations={customizations}
|
||||
items={items}
|
||||
health={character.health}
|
||||
power={character.power}
|
||||
powerLabel={power.label}
|
||||
powerColor={power.color}
|
||||
locale={locale}
|
||||
labels={{
|
||||
loading: t('modelLoading'),
|
||||
unavailable: t('modelUnavailable'),
|
||||
health: t('health'),
|
||||
itemLevel: t('itemLevel'),
|
||||
model: t('model3d'),
|
||||
list: t('equipment'),
|
||||
}}
|
||||
/>
|
||||
|
||||
{specs.primary && (
|
||||
<div className="armory-panel armory-spec-panel">
|
||||
<h2 className="armory-section-title">{t('specialization')}</h2>
|
||||
<div className="armory-specs">
|
||||
<div className="armory-spec-box active">
|
||||
<span
|
||||
className="armory-spec-box-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(specs.primary.icon, 'medium')})` }}
|
||||
/>
|
||||
<div className="armory-spec-box-info">
|
||||
<div className="armory-spec-box-name">{specs.primary.name}</div>
|
||||
<div className="armory-spec-box-dist">{specs.primary.dist.join(' / ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`armory-spec-box ${specs.secondary ? '' : 'na'}`}>
|
||||
{specs.secondary ? (
|
||||
<>
|
||||
<span
|
||||
className="armory-spec-box-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(specs.secondary.icon, 'medium')})` }}
|
||||
/>
|
||||
<div className="armory-spec-box-info">
|
||||
<div className="armory-spec-box-name">{specs.secondary.name}</div>
|
||||
<div className="armory-spec-box-dist">{specs.secondary.dist.join(' / ')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={slot} className="armory-equip-slot">
|
||||
<a
|
||||
href={wowheadUrl('item', item.entry, locale)}
|
||||
data-wowhead={wowheadData('item', item.entry, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`armory-item q${item.quality}`}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
<span className="armory-slot-name">
|
||||
{slotName}
|
||||
{item.itemLevel ? ` · ${item.itemLevel}` : ''}
|
||||
</>
|
||||
) : (
|
||||
<span className="armory-spec-box-na">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{talents && talents.total > 0 && (
|
||||
<div className="armory-panel armory-talents-panel">
|
||||
<h2 className="armory-section-title">{t('talents')}</h2>
|
||||
<WowheadRefresh dep={`talents-${id}`} />
|
||||
<ArmoryTalents
|
||||
trees={talents.trees}
|
||||
spec={talents.spec}
|
||||
total={talents.total}
|
||||
maxPoints={Math.max(0, character.level - 9)}
|
||||
locale={locale}
|
||||
spellIcons={spellIcons}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activityData.entries.length > 0 && (
|
||||
<div className="armory-panel armory-activity-panel">
|
||||
<h2 className="armory-section-title">{t('recentActivity')}</h2>
|
||||
<ArmoryActivity
|
||||
guid={id}
|
||||
locale={locale}
|
||||
initial={activityData.entries}
|
||||
total={activityData.total}
|
||||
pageSize={10}
|
||||
labels={{ earned: t('earned'), search: t('achSearch'), empty: t('noAchievements') }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="armory-panels">
|
||||
<div className="armory-panel">
|
||||
<h2 className="armory-section-title">{t('stats')}</h2>
|
||||
{stats ? (
|
||||
<div className="armory-stats-grid">
|
||||
{statRows.map((r) => (
|
||||
<div className="armory-stat" key={r.label}>
|
||||
<span className="armory-stat-label">{r.label}</span>
|
||||
<span className="armory-stat-value">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="armory-hint armory-stats-pending">{t('statsPending')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="armory-panel">
|
||||
<h2 className="armory-section-title">{t('professions')}</h2>
|
||||
{professions.length ? (
|
||||
<div className="armory-prof-list">
|
||||
{professions.map((p) => (
|
||||
<div className="armory-prof" key={p.skill}>
|
||||
<span className="armory-prof-nameblock">
|
||||
<span className="armory-prof-name">{p.name}</span>
|
||||
{p.rank ? <span className="armory-prof-rank">{p.rank}</span> : null}
|
||||
</span>
|
||||
<span className="armory-prof-value">
|
||||
{p.value}
|
||||
<span className="armory-prof-max"> / {p.max}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="armory-hint">{t('noProfessions')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getActivityPage } from '@/lib/armory'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/** Actividad reciente paginada + búsqueda (por nombre o ID) para la armería. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams
|
||||
const guid = Number(sp.get('guid'))
|
||||
const locale = sp.get('locale') === 'en' ? 'en' : 'es'
|
||||
const page = Math.max(1, Number(sp.get('page')) || 1)
|
||||
const q = sp.get('q') || ''
|
||||
if (!guid) return NextResponse.json({ entries: [], total: 0, pageSize: PAGE_SIZE })
|
||||
const { entries, total } = await getActivityPage(guid, locale, page, PAGE_SIZE, q)
|
||||
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
|
||||
}
|
||||
@@ -86,3 +86,244 @@ a.armory-name:hover { text-decoration: underline; }
|
||||
@media (max-width: 767px) {
|
||||
.armory-model-canvas { height: 320px; }
|
||||
}
|
||||
|
||||
/* ===== Ficha de personaje estilo paperdoll (rediseño nemesis, tema NightSpire) ===== */
|
||||
.armory-sheet { display: flex; flex-direction: column; gap: 20px; }
|
||||
.armory-sheet-head { }
|
||||
|
||||
/* Paperdoll: equipo izq · modelo 3D · equipo der */
|
||||
.armory-paperdoll {
|
||||
display: grid; grid-template-columns: 1fr minmax(300px, 1.15fr) 1fr; gap: 14px; align-items: start;
|
||||
}
|
||||
.armory-gear-col { display: flex; flex-direction: column; gap: 6px; }
|
||||
.armory-model-col { display: flex; flex-direction: column; gap: 10px; }
|
||||
.armory-weapons { display: flex; gap: 6px; }
|
||||
.armory-weapons .armory-cell { flex: 1 1 0; }
|
||||
|
||||
/* Celda de equipo */
|
||||
.armory-cell {
|
||||
display: flex; align-items: center; gap: 9px; padding: 6px 9px; border-radius: 3px; min-height: 46px;
|
||||
background: rgba(0, 0, 0, .22); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
|
||||
text-decoration: none; transition: background .12s;
|
||||
}
|
||||
a.armory-cell:hover { background: rgba(255, 255, 255, .06); }
|
||||
.armory-cell.empty { opacity: .4; }
|
||||
.armory-cell-icon {
|
||||
width: 36px; height: 36px; flex: 0 0 36px; border-radius: 3px;
|
||||
background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07);
|
||||
}
|
||||
.armory-cell-icon.q1 { box-shadow: inset 0 0 0 1px #9d9d9d; }
|
||||
.armory-cell-icon.q2 { box-shadow: inset 0 0 0 1px #1eff00; }
|
||||
.armory-cell-icon.q3 { box-shadow: inset 0 0 0 1px #0070dd; }
|
||||
.armory-cell-icon.q4 { box-shadow: inset 0 0 0 1px #a335ee; }
|
||||
.armory-cell-icon.q5 { box-shadow: inset 0 0 0 1px #ff8000; }
|
||||
.armory-cell-icon.q6 { box-shadow: inset 0 0 0 1px #e6cc80; }
|
||||
.armory-cell-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.armory-cell-name {
|
||||
font-size: 12px; font-weight: bold; line-height: 1.25; text-decoration: none;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.armory-cell-meta { display: flex; gap: 8px; align-items: center; }
|
||||
.armory-cell-ilvl { font-size: 11px; color: #d79602; font-weight: bold; }
|
||||
.armory-cell-slot { font-size: 10px; color: #6d6a5e; text-transform: uppercase; letter-spacing: .03em; }
|
||||
|
||||
/* Paneles inferiores: stats + profesiones */
|
||||
.armory-panels { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
|
||||
.armory-panel {
|
||||
background: rgba(0, 0, 0, .18); border-radius: 4px; padding: 14px 16px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
|
||||
}
|
||||
.armory-stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 20px; }
|
||||
.armory-stat {
|
||||
display: flex; justify-content: space-between; gap: 10px; padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .03);
|
||||
}
|
||||
.armory-stat-label { font-size: 12px; color: #8a8578; }
|
||||
.armory-stat-value { font-size: 13px; font-weight: bold; color: #d4cdbb; }
|
||||
.armory-stats-pending { text-align: left; padding: 8px 0; color: #8a8578; }
|
||||
|
||||
.armory-prof-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.armory-prof { display: flex; justify-content: space-between; gap: 10px; }
|
||||
.armory-prof-name { font-size: 13px; color: #d4cdbb; }
|
||||
.armory-prof-value { font-size: 13px; font-weight: bold; color: #d79602; }
|
||||
.armory-prof-max { color: #6d6a5e; font-weight: normal; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.armory-paperdoll { grid-template-columns: 1fr; }
|
||||
.armory-panels { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===== Paperdoll de iconos (override: tiles solo-icono alrededor del modelo) ===== */
|
||||
.armory-paperdoll {
|
||||
display: grid; grid-template-columns: auto minmax(280px, 1fr) auto; gap: 18px;
|
||||
align-items: start; justify-items: center;
|
||||
}
|
||||
.armory-slots-col { display: flex; flex-direction: column; gap: 9px; }
|
||||
.armory-slot {
|
||||
width: 58px; height: 58px; border-radius: 5px; display: block;
|
||||
background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 2px #3a352c;
|
||||
}
|
||||
.armory-slot.empty { box-shadow: inset 0 0 0 2px #29251e; opacity: .5; }
|
||||
a.armory-slot { transition: transform .1s; }
|
||||
a.armory-slot:hover { transform: scale(1.06); }
|
||||
.armory-slot.q1 { box-shadow: inset 0 0 0 2px #9d9d9d, 0 0 6px rgba(157, 157, 157, .4); }
|
||||
.armory-slot.q2 { box-shadow: inset 0 0 0 2px #1eff00, 0 0 6px rgba(30, 255, 0, .35); }
|
||||
.armory-slot.q3 { box-shadow: inset 0 0 0 2px #0070dd, 0 0 6px rgba(0, 112, 221, .4); }
|
||||
.armory-slot.q4 { box-shadow: inset 0 0 0 2px #a335ee, 0 0 7px rgba(163, 53, 238, .45); }
|
||||
.armory-slot.q5 { box-shadow: inset 0 0 0 2px #ff8000, 0 0 7px rgba(255, 128, 0, .45); }
|
||||
.armory-slot.q6 { box-shadow: inset 0 0 0 2px #e6cc80, 0 0 8px rgba(230, 204, 128, .5); }
|
||||
|
||||
.armory-center { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 460px; }
|
||||
.armory-center .armory-model { margin-top: 0; }
|
||||
.armory-weapons { display: flex; gap: 10px; justify-content: center; }
|
||||
.armory-bars { display: flex; justify-content: space-between; padding: 4px 6px; font-size: 15px; }
|
||||
.armory-bar { color: #8a8578; }
|
||||
.armory-bar.health b { color: #ff5555; }
|
||||
.armory-bar.mana b { color: #4a90d9; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.armory-paperdoll { display: flex; flex-direction: column; align-items: center; }
|
||||
.armory-slots-col { flex-direction: row; flex-wrap: wrap; justify-content: center; }
|
||||
.armory-center { order: -1; }
|
||||
}
|
||||
|
||||
/* ===== Barra de herramientas del paperdoll (toggles MH/OH + vista modelo/lista) ===== */
|
||||
.armory-doll-wrap { display: flex; flex-direction: column; gap: 12px; }
|
||||
.armory-toolbar { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
|
||||
.armory-toggle {
|
||||
font-size: 11px; font-weight: bold; letter-spacing: .03em; padding: 5px 10px; border-radius: 3px;
|
||||
border: 1px solid #2a2723; background: rgba(0, 0, 0, .25); color: #6d6a5e; cursor: pointer; transition: all .12s;
|
||||
}
|
||||
.armory-toggle.on { color: #1eff00; border-color: rgba(30, 255, 0, .35); }
|
||||
.armory-toggle.off { color: #8a8578; }
|
||||
.armory-toggle:hover { background: rgba(255, 255, 255, .06); }
|
||||
.armory-view-switch { display: inline-flex; margin-left: 4px; border: 1px solid #2a2723; border-radius: 3px; overflow: hidden; }
|
||||
.armory-view-switch button {
|
||||
background: rgba(0, 0, 0, .25); color: #6d6a5e; border: 0; padding: 6px 11px; cursor: pointer; font-size: 13px; transition: all .12s;
|
||||
}
|
||||
.armory-view-switch button:hover { background: rgba(255, 255, 255, .06); color: #d4cdbb; }
|
||||
.armory-view-switch button.active { background: #d79602; color: #14110d; }
|
||||
|
||||
/* Vista lista de equipo */
|
||||
.armory-listview { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 6px; }
|
||||
.armory-listrow {
|
||||
display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 3px; text-decoration: none;
|
||||
background: rgba(0, 0, 0, .2); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03); transition: background .12s;
|
||||
}
|
||||
.armory-listrow:hover { background: rgba(255, 255, 255, .05); }
|
||||
.armory-slot.small { width: 38px; height: 38px; flex: 0 0 38px; border-radius: 3px; }
|
||||
.armory-listrow-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.armory-listrow .armory-cell-name {
|
||||
font-size: 12px; font-weight: bold; text-decoration: none; line-height: 1.2;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Icono del ítem como capa hija (wowhead sobrescribe el background del <a>, no el hijo) */
|
||||
.armory-slot { position: relative; overflow: hidden; }
|
||||
.armory-slot-icon {
|
||||
position: absolute; inset: 2px; border-radius: 3px;
|
||||
background-position: center; background-size: cover; background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* Badge de puntos de logros en la cabecera */
|
||||
.armory-achpoints { font-size: 13px; font-weight: bold; color: #d79602; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.armory-achpoints i { font-size: 12px; }
|
||||
|
||||
/* Panel de talentos */
|
||||
.armory-talents-panel { background: rgba(0,0,0,.18); border-radius: 4px; padding: 14px 16px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); }
|
||||
.armory-spec { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
|
||||
.armory-spec-name { font-size: 16px; font-weight: bold; color: #d79602; }
|
||||
.armory-spec-dist { font-size: 13px; color: #8a8578; font-weight: bold; letter-spacing: .05em; }
|
||||
.armory-trees { display: flex; flex-direction: column; gap: 8px; }
|
||||
.armory-tree { display: grid; grid-template-columns: 130px 1fr 32px; align-items: center; gap: 10px; }
|
||||
.armory-tree-name { font-size: 13px; color: #d4cdbb; }
|
||||
.armory-tree-bar { position: relative; height: 8px; background: rgba(255,255,255,.05); border-radius: 4px; overflow: hidden; }
|
||||
.armory-tree-bar > span { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #a8791a, #d79602); border-radius: 4px; }
|
||||
.armory-tree-pts { font-size: 13px; font-weight: bold; color: #d4cdbb; text-align: right; }
|
||||
|
||||
/* ===== Árbol de talentos (rejilla con iconos) ===== */
|
||||
.armory-spec-total { margin-left: auto; font-size: 12px; color: #6d6a5e; }
|
||||
.armory-talent-trees { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.armory-talent-tree { background: rgba(0,0,0,.2); border-radius: 4px; padding: 10px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); }
|
||||
.armory-talent-tree-head { display: flex; justify-content: space-between; font-size: 13px; font-weight: bold; color: #d4cdbb; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.armory-talent-tree-pts { color: #d79602; }
|
||||
.armory-talent-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; justify-items: center; }
|
||||
.armory-talent { position: relative; width: 40px; height: 40px; border-radius: 5px; display: block; background: #0d0b08; box-shadow: inset 0 0 0 1px #2a2723; overflow: hidden; }
|
||||
.armory-talent-icon { position: absolute; inset: 1px; background-position: center; background-size: cover; border-radius: 4px; }
|
||||
.armory-talent.off .armory-talent-icon { filter: grayscale(1) brightness(.45); }
|
||||
.armory-talent.on { box-shadow: inset 0 0 0 2px #1eff00, 0 0 5px rgba(30,255,0,.35); }
|
||||
.armory-talent.max { box-shadow: inset 0 0 0 2px #d79602, 0 0 6px rgba(215,150,2,.45); }
|
||||
.armory-talent-rank { position: absolute; right: 0; bottom: 0; z-index: 2; font-size: 10px; font-weight: bold; line-height: 1; padding: 1px 3px; border-radius: 3px 0 0 0; background: rgba(0,0,0,.82); color: #fff; }
|
||||
.armory-talent.max .armory-talent-rank { color: #ffd100; }
|
||||
@media (max-width: 700px) { .armory-talent-trees { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Compactar la rejilla: columnas de ancho fijo y centradas (como el juego) */
|
||||
.armory-talent-grid { grid-template-columns: repeat(4, 44px); gap: 10px; justify-content: center; }
|
||||
.armory-talent { width: 44px; height: 44px; }
|
||||
|
||||
/* wowhead (iconizeLinks) inyecta un background-image inline en el <a>; sin su CSS
|
||||
se repite en mosaico (visible en la vista lista). Ponemos nuestros propios iconos
|
||||
en un <span> hijo, así que anulamos el background que inyecta wowhead. */
|
||||
a.armory-slot, a.armory-listrow, a.armory-talent { background-image: none !important; }
|
||||
|
||||
/* ===== Panel de Especialización (spec primaria + doble spec) ===== */
|
||||
.armory-specs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.armory-spec-box {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 4px; min-height: 64px;
|
||||
background: rgba(0, 0, 0, .22); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04);
|
||||
}
|
||||
.armory-spec-box.active { box-shadow: inset 0 0 0 1px rgba(215, 150, 2, .55), 0 0 8px rgba(215, 150, 2, .12); }
|
||||
.armory-spec-box.na { justify-content: center; opacity: .5; }
|
||||
.armory-spec-box-icon {
|
||||
width: 46px; height: 46px; flex: 0 0 46px; border-radius: 5px;
|
||||
background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 2px #d79602;
|
||||
}
|
||||
.armory-spec-box-name { font-size: 16px; font-weight: bold; color: #d79602; }
|
||||
.armory-spec-box-dist { font-size: 13px; color: #8a8578; font-weight: bold; letter-spacing: .05em; margin-top: 2px; }
|
||||
.armory-spec-box-na { font-size: 20px; font-weight: bold; color: #6d6a5e; letter-spacing: .12em; }
|
||||
@media (max-width: 600px) { .armory-specs { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Rango de profesión bajo el nombre */
|
||||
.armory-prof-nameblock { display: flex; flex-direction: column; gap: 1px; }
|
||||
.armory-prof-rank { font-size: 11px; color: #6d6a5e; }
|
||||
|
||||
/* Nivel de objeto medio junto al nombre del personaje */
|
||||
.armory-char-ilvl { font-size: 15px; font-weight: normal; color: #8a8578; }
|
||||
|
||||
/* ===== Actividad reciente (logros con fecha) ===== */
|
||||
.armory-activity { display: flex; flex-direction: column; }
|
||||
.armory-activity-row { display: flex; align-items: center; gap: 11px; padding: 9px 4px; border-bottom: 1px solid rgba(255,255,255,.04); }
|
||||
.armory-activity-row:last-child { border-bottom: 0; }
|
||||
.armory-activity-icon { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 4px; background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||
.armory-activity-text { flex: 1; font-size: 13px; color: #8a8578; }
|
||||
.armory-activity-link { font-weight: bold; text-decoration: none; }
|
||||
.armory-activity-link:hover { text-decoration: underline; }
|
||||
.armory-activity-date { font-size: 12px; color: #6d6a5e; font-style: italic; white-space: nowrap; }
|
||||
/* Evitar el mosaico de icono que inyecta wowhead en el enlace */
|
||||
a.armory-activity-link { background-image: none !important; }
|
||||
|
||||
/* Botón "Ver primera actividad" */
|
||||
.armory-activity-more {
|
||||
display: block; width: 100%; margin-top: 12px; padding: 13px; border-radius: 4px; cursor: pointer;
|
||||
background: rgba(0,0,0,.25); border: 1px solid #2a2723; color: #8a8578;
|
||||
font-weight: bold; text-transform: uppercase; letter-spacing: .08em; font-size: 12px; transition: all .12s;
|
||||
}
|
||||
.armory-activity-more:hover:not(:disabled) { color: #d79602; border-color: rgba(215,150,2,.4); }
|
||||
.armory-activity-more:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
/* Buscador de logros + paginación */
|
||||
.armory-ach-search {
|
||||
width: 100%; margin-bottom: 10px; padding: 9px 12px; border-radius: 4px;
|
||||
background: #14110d; border: 1px solid #2a2723; color: #d4cdbb; font-size: 13px;
|
||||
}
|
||||
.armory-ach-search:focus { outline: none; border-color: #d79602; }
|
||||
.armory-activity.loading { opacity: .5; pointer-events: none; }
|
||||
.armory-pager { display: flex; gap: 4px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
|
||||
.armory-pager button {
|
||||
min-width: 34px; padding: 6px 10px; border-radius: 3px; cursor: pointer; font-weight: bold; font-size: 13px;
|
||||
background: rgba(0,0,0,.25); border: 1px solid #2a2723; color: #8a8578; transition: all .12s;
|
||||
}
|
||||
.armory-pager button:hover:not(:disabled) { color: #d79602; border-color: rgba(215,150,2,.4); }
|
||||
.armory-pager button.active { background: #d79602; color: #14110d; border-color: #d79602; }
|
||||
.armory-pager button:disabled { opacity: .4; cursor: default; }
|
||||
.armory-pager-dots { color: #6d6a5e; padding: 6px 2px; align-self: center; }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* Proxy con CACHÉ EN DISCO del contenido del visor de modelos de wowhead.
|
||||
*
|
||||
* Sustituye al antiguo rewrite (que solo hacía de proxy transparente). La primera
|
||||
* vez que el visor pide un fichero (/modelviewer/<rama>/...), se descarga de
|
||||
* wow.zamimg.com y se guarda en disco; a partir de ahí se sirve desde nuestro
|
||||
* servidor. Así la armería NO depende de que wowhead siga disponible: lo ya visto
|
||||
* queda cacheado localmente (mismo enfoque que el /wmmv-cache/ de otras armerías).
|
||||
*
|
||||
* Same-origin, por lo que además evita el bloqueo CORS del visor.
|
||||
*/
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UPSTREAM = 'https://wow.zamimg.com/modelviewer/'
|
||||
const CACHE_DIR = process.env.MODELVIEWER_CACHE_DIR || '/root/NightSpire/wmmv-cache'
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.mo3': 'application/octet-stream',
|
||||
'.m2': 'application/octet-stream',
|
||||
'.blp': 'application/octet-stream',
|
||||
'.ktx': 'application/octet-stream',
|
||||
'.bone': 'application/octet-stream',
|
||||
'.wasm': 'application/wasm',
|
||||
}
|
||||
|
||||
function contentType(rel: string): string {
|
||||
return TYPES[path.extname(rel).toLowerCase()] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function safeRel(parts: string[]): string | null {
|
||||
const rel = (parts || []).join('/')
|
||||
if (!rel || rel.includes('..') || rel.includes('\0') || rel.startsWith('/')) return null
|
||||
return rel
|
||||
}
|
||||
|
||||
const cacheHeaders = (ct: string, hit: boolean): HeadersInit => ({
|
||||
'Content-Type': ct,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Cache': hit ? 'HIT' : 'MISS',
|
||||
})
|
||||
|
||||
/**
|
||||
* Descarga de wowhead probando ramas alternativas. El visor pide todo bajo `live/`
|
||||
* (que tiene geometría), pero muchos metadatos/texturas de ítems de WotLK solo
|
||||
* existen en `wrath`/`cata`. Si `live` da 404, se reintenta con esas ramas y se
|
||||
* cachea el resultado bajo la ruta `live/` original que pidió el visor.
|
||||
*/
|
||||
async function fetchWithFallback(rel: string): Promise<Response | null> {
|
||||
const candidates = [rel]
|
||||
if (rel.startsWith('live/')) {
|
||||
const tail = rel.slice('live/'.length)
|
||||
candidates.push('wrath/' + tail, 'cata/' + tail)
|
||||
}
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const r = await fetch(UPSTREAM + c, { headers: { 'User-Agent': 'NightSpire-Armory/1.0' } })
|
||||
if (r.ok) return r
|
||||
} catch {
|
||||
/* probar siguiente rama */
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path: parts } = await params
|
||||
const rel = safeRel(parts)
|
||||
if (!rel) return new Response('Bad path', { status: 400 })
|
||||
|
||||
const filePath = path.join(CACHE_DIR, rel)
|
||||
const ct = contentType(rel)
|
||||
|
||||
// 1) Servir desde la caché local si existe.
|
||||
try {
|
||||
const buf = await fs.readFile(filePath)
|
||||
return new Response(buf, { headers: cacheHeaders(ct, true) })
|
||||
} catch {
|
||||
/* no cacheado todavía */
|
||||
}
|
||||
|
||||
// 2) Descargar de wowhead (con reintento en ramas alternativas).
|
||||
const upstream = await fetchWithFallback(rel)
|
||||
if (!upstream) return new Response(null, { status: 404 })
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer())
|
||||
|
||||
// 3) Guardar en disco de forma atómica (temp + rename) y servir.
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
const tmp = `${filePath}.${process.pid}.${buf.length}.tmp`
|
||||
await fs.writeFile(tmp, buf)
|
||||
await fs.rename(tmp, filePath)
|
||||
} catch {
|
||||
/* si no se pudo guardar, servimos igualmente desde memoria */
|
||||
}
|
||||
|
||||
return new Response(buf, { headers: cacheHeaders(ct, false) })
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type ActivityEntry = { id: number; name: string; icon: string; date: number }
|
||||
|
||||
/**
|
||||
* Navegador de logros del personaje: búsqueda en tiempo real (por nombre o ID) y
|
||||
* paginación (anterior/siguiente + números). Los datos se piden a /api/armory/activity;
|
||||
* la primera página llega ya renderizada del servidor.
|
||||
*/
|
||||
export function ArmoryActivity({
|
||||
guid,
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
guid: number
|
||||
locale: string
|
||||
initial: ActivityEntry[]
|
||||
total: number
|
||||
pageSize: number
|
||||
labels: { earned: string; search: string; empty: string }
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [q, setQ] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, query: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/armory/activity?guid=${guid}&page=${p}&q=${encodeURIComponent(query)}&locale=${locale}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Búsqueda en tiempo real (con retardo para no consultar en cada tecla).
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, q)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, q)
|
||||
}
|
||||
|
||||
const fmt = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, page - win)
|
||||
const to = Math.min(pages, page + win)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
return (
|
||||
<>
|
||||
<WowheadRefresh dep={`${page}-${q}-${entries.length}`} />
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={labels.search}
|
||||
aria-label={labels.search}
|
||||
/>
|
||||
<div className={`armory-activity${loading ? ' loading' : ''}`}>
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
entries.map((a, i) => (
|
||||
<div className="armory-activity-row" key={`${a.id}-${a.date}-${i}`}>
|
||||
<span
|
||||
className="armory-activity-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(a.icon, 'small')})` }}
|
||||
/>
|
||||
<span className="armory-activity-text">
|
||||
{labels.earned}{' '}
|
||||
<a
|
||||
className="armory-activity-link"
|
||||
href={wowheadUrl('achievement', a.id, locale)}
|
||||
data-wowhead={wowheadData('achievement', a.id, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
[{a.name}]
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<span className="armory-activity-date">{fmt(a.date)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => goto(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((p) => (
|
||||
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => goto(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,25 +3,61 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Visor 3D del personaje. Usa la librería `wow-model-viewer` (generateModels), que a
|
||||
* su vez necesita jQuery y el ZamModelViewer de wowhead (wow.zamimg.com) cargados en
|
||||
* global. Se cargan bajo demanda solo en esta ficha.
|
||||
* Visor 3D del personaje (ZamModelViewer de wowhead, servido bajo /modelviewer/*
|
||||
* como proxy same-origin para evitar CORS).
|
||||
*
|
||||
* OJO: es integración cliente que depende de un CDN externo (zamimg) y no se puede
|
||||
* verificar sin navegador; por eso todo va envuelto en try/catch y con un temporizador
|
||||
* de reserva: si el modelo no aparece, se muestra un aviso en lugar de romper la ficha.
|
||||
* Problema de fondo (3.4.3 sobre CDN de wowhead):
|
||||
* - La rama `wrath` SÍ tiene los IDs de personalización de WotLK que guarda
|
||||
* AzerothCore, pero NO publica la geometría de los modelos (los .mo3 dan 404).
|
||||
* - La rama `live` (retail) SÍ tiene geometría y texturas, pero sus choiceID son
|
||||
* los de retail actual, distintos a los de WotLK → la piel salía negra.
|
||||
*
|
||||
* El equipo se pasa como {slot, entry, displayid}; la librería mapea cada ítem a su
|
||||
* slot del visor. displayid = transmog si lo hay, si no el del ítem equipado.
|
||||
* Solución: renderizar con geometría de `live` y REMAPEAR cada choiceID de la BD
|
||||
* (WotLK) a su equivalente retail por POSICIÓN dentro de la opción, usando el orden
|
||||
* de choices de `wrath` como referencia. Así la piel/cara/pelo se ven correctos.
|
||||
*/
|
||||
|
||||
// Same-origin (proxy /modelviewer/* -> zamimg en next.config): evita el bloqueo CORS
|
||||
// del visor, que es lo que dejaba el modelo «no disponible».
|
||||
const ZAM_VIEWER = '/modelviewer/live/viewer/viewer.min.js'
|
||||
const GEO_BRANCH = 'live' // geometría + texturas (wrath no publica modelos)
|
||||
const CONTENT_PATH = `/modelviewer/${GEO_BRANCH}/`
|
||||
const ZAM_VIEWER = `/modelviewer/${GEO_BRANCH}/viewer/viewer.min.js`
|
||||
const JQUERY = 'https://code.jquery.com/jquery-3.7.1.min.js'
|
||||
const CONTENT_PATH = '/modelviewer/live/'
|
||||
const MODEL_TYPE_CHARACTER = 16
|
||||
|
||||
type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string }
|
||||
type Choice = { Id: number }
|
||||
type Option = { Id: number; Name?: string; Choices: Choice[] }
|
||||
type CustomizationDoc = { Options?: Option[]; data?: { Options?: Option[] } }
|
||||
|
||||
type Win = typeof window & {
|
||||
jQuery?: (sel: string) => unknown
|
||||
ZamModelViewer?: new (opts: unknown) => unknown
|
||||
CONTENT_PATH?: string
|
||||
WOTLK_TO_RETAIL_DISPLAY_ID_API?: unknown
|
||||
}
|
||||
|
||||
function ensureWH() {
|
||||
const w = window as unknown as { WH?: Record<string, unknown> }
|
||||
const WH = (w.WH = w.WH || {})
|
||||
if (typeof WH.debug !== 'function') WH.debug = () => {}
|
||||
if (!WH.defaultAnimation) WH.defaultAnimation = 'Stand'
|
||||
if (!WH.WebP) WH.WebP = { getImageExtension: () => '.webp' }
|
||||
if (!WH.Wow) {
|
||||
WH.Wow = {
|
||||
Item: {
|
||||
INVENTORY_TYPE_HEAD: 1, INVENTORY_TYPE_NECK: 2, INVENTORY_TYPE_SHOULDERS: 3,
|
||||
INVENTORY_TYPE_SHIRT: 4, INVENTORY_TYPE_CHEST: 5, INVENTORY_TYPE_WAIST: 6,
|
||||
INVENTORY_TYPE_LEGS: 7, INVENTORY_TYPE_FEET: 8, INVENTORY_TYPE_WRISTS: 9,
|
||||
INVENTORY_TYPE_HANDS: 10, INVENTORY_TYPE_FINGER: 11, INVENTORY_TYPE_TRINKET: 12,
|
||||
INVENTORY_TYPE_ONE_HAND: 13, INVENTORY_TYPE_SHIELD: 14, INVENTORY_TYPE_RANGED: 15,
|
||||
INVENTORY_TYPE_BACK: 16, INVENTORY_TYPE_TWO_HAND: 17, INVENTORY_TYPE_BAG: 18,
|
||||
INVENTORY_TYPE_TABARD: 19, INVENTORY_TYPE_ROBE: 20, INVENTORY_TYPE_MAIN_HAND: 21,
|
||||
INVENTORY_TYPE_OFF_HAND: 22, INVENTORY_TYPE_HELD_IN_OFF_HAND: 23,
|
||||
INVENTORY_TYPE_PROJECTILE: 24, INVENTORY_TYPE_THROWN: 25, INVENTORY_TYPE_RANGED_RIGHT: 26,
|
||||
INVENTORY_TYPE_QUIVER: 27, INVENTORY_TYPE_RELIC: 28, INVENTORY_TYPE_PROFESSION_TOOL: 29,
|
||||
INVENTORY_TYPE_PROFESSION_ACCESSORY: 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -35,15 +71,57 @@ function loadScript(src: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function optionsById(doc: CustomizationDoc): Map<number, Option> {
|
||||
const list = doc.Options ?? doc.data?.Options ?? []
|
||||
return new Map(list.map((o) => [o.Id, o]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remapea los choiceID de WotLK (de la BD) a choiceID de retail por índice.
|
||||
* Usa el orden de choices de `wrath` (WotLK) para hallar la posición, y el de `live`
|
||||
* (retail) para obtener el ID equivalente. Si algo falla, devuelve lo que pueda.
|
||||
*/
|
||||
async function remapCustomizations(
|
||||
raceGender: number,
|
||||
db: { optionId: number; choiceId: number }[],
|
||||
): Promise<{ optionId: number; choiceId: number }[]> {
|
||||
try {
|
||||
const [wrathDoc, liveDoc] = await Promise.all([
|
||||
fetch(`/modelviewer/wrath/meta/charactercustomization/${raceGender}.json`).then((r) => r.json()),
|
||||
fetch(`/modelviewer/live/meta/charactercustomization/${raceGender}.json`).then((r) => r.json()),
|
||||
])
|
||||
const W = optionsById(wrathDoc as CustomizationDoc)
|
||||
const L = optionsById(liveDoc as CustomizationDoc)
|
||||
const out: { optionId: number; choiceId: number }[] = []
|
||||
for (const { optionId, choiceId } of db) {
|
||||
const wo = W.get(optionId)
|
||||
const lo = L.get(optionId)
|
||||
if (!wo || !lo) continue
|
||||
const idx = wo.Choices.findIndex((c) => c.Id === choiceId)
|
||||
if (idx < 0) continue
|
||||
const lc = lo.Choices[idx] ?? lo.Choices[lo.Choices.length - 1]
|
||||
if (lc) out.push({ optionId, choiceId: lc.Id })
|
||||
}
|
||||
return out
|
||||
} catch (err) {
|
||||
console.warn('[armory-3d] no se pudo remapear la apariencia:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function ArmoryModel3D({
|
||||
race,
|
||||
gender,
|
||||
equip,
|
||||
customizations,
|
||||
labels,
|
||||
}: {
|
||||
race: number
|
||||
gender: number
|
||||
equip: { slot: number; entry: number; displayid: number }[]
|
||||
/** Equipo ya resuelto a slots del visor: [slotVisor, displayId]. */
|
||||
equip: { slot: number; display: number }[]
|
||||
/** Apariencia desde character_customizations (IDs de WotLK): {optionId, choiceId}. */
|
||||
customizations: { optionId: number; choiceId: number }[]
|
||||
labels: { loading: string; unavailable: string }
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -51,33 +129,45 @@ export function ArmoryModel3D({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const failTimer = setTimeout(() => !cancelled && setState((s) => (s === 'loading' ? 'fail' : s)), 12000)
|
||||
const failTimer = setTimeout(() => !cancelled && setState((s) => (s === 'loading' ? 'fail' : s)), 15000)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const w = window as Win
|
||||
w.CONTENT_PATH = CONTENT_PATH
|
||||
w.WOTLK_TO_RETAIL_DISPLAY_ID_API = false
|
||||
|
||||
const raceGender = race * 2 - 1 + gender
|
||||
// El remapeo (peticiones fetch) puede ir en paralelo, pero jQuery debe cargar
|
||||
// ANTES que viewer.min.js (que lo usa como global), así que ese orden es estricto.
|
||||
const remapPromise = remapCustomizations(raceGender, customizations)
|
||||
await loadScript(JQUERY)
|
||||
await loadScript(ZAM_VIEWER)
|
||||
// La librería es ESM y usa jQuery/ZamModelViewer globales.
|
||||
const mv = await import('wow-model-viewer')
|
||||
const options = await remapPromise
|
||||
ensureWH()
|
||||
if (cancelled || !ref.current) return
|
||||
|
||||
// La resolución de ítems hace consultas al CDN y puede fallar; si falla, se
|
||||
// renderiza al menos el personaje sin equipo en vez de dejar la ficha vacía.
|
||||
let items: unknown[] = []
|
||||
try {
|
||||
items = await mv.findItemsInEquipments(
|
||||
equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })),
|
||||
'live',
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[armory-3d] no se pudieron resolver los ítems del modelo:', err)
|
||||
}
|
||||
if (cancelled || !ref.current) return
|
||||
const jQuery = w.jQuery
|
||||
const ZamModelViewer = w.ZamModelViewer
|
||||
if (!jQuery || !ZamModelViewer) throw new Error('ZamModelViewer/jQuery no disponibles')
|
||||
|
||||
ref.current.id = ref.current.id || `armory-model-${race}-${gender}`
|
||||
await mv.generateModels(1, `#${ref.current.id}`, { race, gender, items }, 'live')
|
||||
const items = equip.filter((e) => e.display > 0).map((e) => [e.slot, e.display])
|
||||
|
||||
const models = {
|
||||
type: 2,
|
||||
contentPath: CONTENT_PATH,
|
||||
container: jQuery(`#${ref.current.id}`),
|
||||
aspect: 1,
|
||||
hd: true,
|
||||
items,
|
||||
models: { id: raceGender, type: MODEL_TYPE_CHARACTER },
|
||||
charCustomization: { options },
|
||||
}
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
new ZamModelViewer(models)
|
||||
|
||||
if (!cancelled) {
|
||||
clearTimeout(failTimer)
|
||||
setState('ok')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArmoryModel3D } from './ArmoryModel3D'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { slotLabel, PAPERDOLL_LEFT, PAPERDOLL_RIGHT, PAPERDOLL_WEAPONS } from '@/lib/wow-data'
|
||||
import { wowheadUrl, wowheadData, wowheadIcon } from '@/lib/wowhead'
|
||||
|
||||
export type SheetItem = {
|
||||
slot: number
|
||||
entry: number
|
||||
name: string
|
||||
quality: number
|
||||
itemLevel: number
|
||||
inventoryType: number
|
||||
displayId: number | null
|
||||
transmogDisplayId: number | null
|
||||
ench: number
|
||||
gems: number[]
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Paperdoll interactivo de la ficha: iconos de equipo alrededor del modelo 3D, con
|
||||
* conmutador modelo/lista y toggles MH/OH para mostrar u ocultar las armas en el
|
||||
* modelo. Cliente porque maneja estado; los datos llegan ya resueltos del servidor.
|
||||
*/
|
||||
export function ArmoryPaperdoll({
|
||||
race,
|
||||
gender,
|
||||
customizations,
|
||||
items,
|
||||
health,
|
||||
power,
|
||||
powerLabel,
|
||||
powerColor,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
race: number
|
||||
gender: number
|
||||
customizations: { optionId: number; choiceId: number }[]
|
||||
items: SheetItem[]
|
||||
health: number
|
||||
power: number
|
||||
powerLabel: string
|
||||
powerColor: string
|
||||
locale: string
|
||||
labels: { loading: string; unavailable: string; health: string; itemLevel: string; model: string; list: string }
|
||||
}) {
|
||||
const [view, setView] = useState<'model' | 'list'>('model')
|
||||
const [mh, setMh] = useState(true)
|
||||
const [oh, setOh] = useState(true)
|
||||
|
||||
const bySlot = new Map(items.map((i) => [i.slot, i]))
|
||||
|
||||
const HIDDEN = new Set([1, 10, 11, 12, 13])
|
||||
const viewerSlot = (s: number, inv: number): number => {
|
||||
if (s === 15) return 21
|
||||
if (s === 16) return inv === 23 ? 23 : 22
|
||||
if (s === 17) return inv === 15 || inv === 26 ? inv : 0
|
||||
return inv
|
||||
}
|
||||
const modelEquip = items
|
||||
.filter((e) => !HIDDEN.has(e.slot))
|
||||
.filter((e) => (e.slot === 15 ? mh : e.slot === 16 ? oh : true))
|
||||
.map((e) => ({ slot: viewerSlot(e.slot, e.inventoryType), display: e.transmogDisplayId ?? e.displayId ?? 0 }))
|
||||
.filter((e) => e.slot > 0 && e.display > 0)
|
||||
|
||||
const Tile = ({ slot }: { slot: number }) => {
|
||||
const it = bySlot.get(slot)
|
||||
const label = slotLabel(slot, locale)
|
||||
if (!it) return <div className="armory-slot empty" title={label} />
|
||||
return (
|
||||
<a
|
||||
className={`armory-slot q${it.quality}`}
|
||||
href={wowheadUrl('item', it.entry, locale)}
|
||||
data-wowhead={wowheadData('item', it.entry, locale, { ench: it.ench, gems: it.gems })}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={it.name}
|
||||
>
|
||||
{it.icon ? (
|
||||
<span className="armory-slot-icon" style={{ backgroundImage: `url(${wowheadIcon(it.icon, 'large')})` }} />
|
||||
) : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const ListRow = ({ item }: { item: SheetItem }) => (
|
||||
<a
|
||||
className="armory-listrow"
|
||||
href={wowheadUrl('item', item.entry, locale)}
|
||||
data-wowhead={wowheadData('item', item.entry, locale, { ench: item.ench, gems: item.gems })}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className={`armory-slot small q${item.quality}`}>
|
||||
{item.icon ? (
|
||||
<span className="armory-slot-icon" style={{ backgroundImage: `url(${wowheadIcon(item.icon, 'small')})` }} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="armory-listrow-info">
|
||||
<span className={`armory-cell-name q${item.quality}`}>{item.name}</span>
|
||||
<span className="armory-cell-slot">{slotLabel(item.slot, locale)}</span>
|
||||
</span>
|
||||
{item.itemLevel ? <span className="armory-cell-ilvl">{item.itemLevel}</span> : null}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="armory-doll-wrap">
|
||||
<WowheadRefresh dep={`${view}-${mh}-${oh}`} />
|
||||
|
||||
<div className="armory-toolbar">
|
||||
<button className={`armory-toggle ${mh ? 'on' : 'off'}`} onClick={() => setMh((v) => !v)} type="button">
|
||||
MH:{mh ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<button className={`armory-toggle ${oh ? 'on' : 'off'}`} onClick={() => setOh((v) => !v)} type="button">
|
||||
OH:{oh ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<span className="armory-view-switch">
|
||||
<button className={view === 'model' ? 'active' : ''} onClick={() => setView('model')} type="button" aria-label={labels.model}>
|
||||
<i className="fas fa-user" />
|
||||
</button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} type="button" aria-label={labels.list}>
|
||||
<i className="fas fa-th-list" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view === 'model' ? (
|
||||
<div className="armory-paperdoll">
|
||||
<div className="armory-slots-col">
|
||||
{PAPERDOLL_LEFT.map((s) => (
|
||||
<Tile key={s} slot={s} />
|
||||
))}
|
||||
</div>
|
||||
<div className="armory-center">
|
||||
<div className="armory-model-wrap">
|
||||
<ArmoryModel3D
|
||||
key={`${mh}-${oh}`}
|
||||
race={race}
|
||||
gender={gender}
|
||||
equip={modelEquip}
|
||||
customizations={customizations}
|
||||
labels={{ loading: labels.loading, unavailable: labels.unavailable }}
|
||||
/>
|
||||
</div>
|
||||
<div className="armory-weapons">
|
||||
{PAPERDOLL_WEAPONS.map((s) => (
|
||||
<Tile key={s} slot={s} />
|
||||
))}
|
||||
</div>
|
||||
<div className="armory-bars">
|
||||
<span className="armory-bar health">
|
||||
{labels.health}: <b>{health.toLocaleString(locale)}</b>
|
||||
</span>
|
||||
<span className="armory-bar power">
|
||||
{powerLabel}: <b style={{ color: powerColor }}>{power.toLocaleString(locale)}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="armory-slots-col">
|
||||
{PAPERDOLL_RIGHT.map((s) => (
|
||||
<Tile key={s} slot={s} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="armory-listview">
|
||||
{[...PAPERDOLL_LEFT, ...PAPERDOLL_RIGHT, ...PAPERDOLL_WEAPONS]
|
||||
.map((s) => bySlot.get(s))
|
||||
.filter((it): it is SheetItem => !!it)
|
||||
.map((it) => (
|
||||
<ListRow key={it.slot} item={it} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { wowheadUrl, wowheadData, wowheadIcon } from '@/lib/wowhead'
|
||||
|
||||
export type TalentCell = { tier: number; col: number; rank: number; max: number; spell: number }
|
||||
export type TalentTreeData = { index: number; name: string; points: number; talents: TalentCell[] }
|
||||
|
||||
/**
|
||||
* Árbol de talentos estilo calculador: 3 árboles, cada uno una rejilla de casillas
|
||||
* posicionadas por fila (tier) y columna. Cada casilla muestra el icono del talento
|
||||
* (del hechizo de rango 1) y su rango actual/máximo; las no aprendidas van atenuadas.
|
||||
* El icono va en un <span> hijo porque wowhead sobrescribe el background del <a>.
|
||||
*/
|
||||
export function ArmoryTalents({
|
||||
trees,
|
||||
spec,
|
||||
total,
|
||||
maxPoints,
|
||||
locale,
|
||||
spellIcons,
|
||||
}: {
|
||||
trees: TalentTreeData[]
|
||||
spec: string | null
|
||||
total: number
|
||||
maxPoints: number
|
||||
locale: string
|
||||
spellIcons: Record<number, string>
|
||||
}) {
|
||||
return (
|
||||
<div className="armory-talents">
|
||||
<div className="armory-spec">
|
||||
<span className="armory-spec-name">{spec}</span>
|
||||
<span className="armory-spec-dist">{trees.map((tr) => tr.points).join(' / ')}</span>
|
||||
<span className="armory-spec-total">
|
||||
{total} / {maxPoints}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-talent-trees">
|
||||
{trees.map((tree) => {
|
||||
const maxTier = tree.talents.reduce((m, t) => Math.max(m, t.tier), 0)
|
||||
return (
|
||||
<div className="armory-talent-tree" key={tree.index}>
|
||||
<div className="armory-talent-tree-head">
|
||||
<span>{tree.name}</span>
|
||||
<span className="armory-talent-tree-pts">{tree.points}</span>
|
||||
</div>
|
||||
<div className="armory-talent-grid" style={{ gridTemplateRows: `repeat(${maxTier + 1}, 44px)` }}>
|
||||
{tree.talents.map((tal) => {
|
||||
const icon = spellIcons[tal.spell]
|
||||
const state = tal.rank === 0 ? 'off' : tal.rank >= tal.max ? 'max' : 'on'
|
||||
return (
|
||||
<a
|
||||
key={`${tal.tier}-${tal.col}`}
|
||||
className={`armory-talent ${state}`}
|
||||
style={{ gridRow: tal.tier + 1, gridColumn: tal.col + 1 }}
|
||||
href={wowheadUrl('spell', tal.spell, locale)}
|
||||
data-wowhead={wowheadData('spell', tal.spell, locale)}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
className="armory-talent-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(icon, 'medium')})` }}
|
||||
/>
|
||||
) : null}
|
||||
{tal.rank > 0 ? (
|
||||
<span className="armory-talent-rank">
|
||||
{tal.rank}/{tal.max}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -1709,7 +1709,8 @@
|
||||
"help": "HELP",
|
||||
"myAccount": "MY ACCOUNT",
|
||||
"logout": "LOG OUT",
|
||||
"login": "LOG IN"
|
||||
"login": "LOG IN",
|
||||
"armory": "ARMORY"
|
||||
},
|
||||
"footer": {
|
||||
"terms": "Terms and Conditions",
|
||||
@@ -2032,6 +2033,35 @@
|
||||
"rank": "Rank",
|
||||
"leader": "Leader",
|
||||
"noEquipment": "No visible equipment.",
|
||||
"backToArmory": "Back to armory"
|
||||
"backToArmory": "Back to armory",
|
||||
"stats": "Statistics",
|
||||
"statsPending": "Stats will appear once the character logs back in.",
|
||||
"professions": "Professions",
|
||||
"noProfessions": "No professions",
|
||||
"strength": "Strength",
|
||||
"agility": "Agility",
|
||||
"stamina": "Stamina",
|
||||
"intellect": "Intellect",
|
||||
"armor": "Armor",
|
||||
"attackPower": "Attack Power",
|
||||
"rangedAttackPower": "Ranged Attack Power",
|
||||
"spellPower": "Spell Power",
|
||||
"critChance": "Crit Chance",
|
||||
"spellCrit": "Spell Crit",
|
||||
"dodge": "Dodge",
|
||||
"parry": "Parry",
|
||||
"block": "Block",
|
||||
"resilience": "Resilience",
|
||||
"health": "Health",
|
||||
"mana": "Mana",
|
||||
"achievementPoints": "Achievement points",
|
||||
"talents": "Talents",
|
||||
"noTalents": "No talents",
|
||||
"specialization": "Specialization",
|
||||
"recentActivity": "Recent Activity",
|
||||
"earned": "Earned",
|
||||
"viewFirstActivity": "View first activity",
|
||||
"achSearch": "Search achievement by name or ID…",
|
||||
"noAchievements": "No achievements"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1709,7 +1709,8 @@
|
||||
"help": "AYUDA",
|
||||
"myAccount": "MI CUENTA",
|
||||
"logout": "DESCONECTAR",
|
||||
"login": "CONECTAR"
|
||||
"login": "CONECTAR",
|
||||
"armory": "ARMERÍA"
|
||||
},
|
||||
"footer": {
|
||||
"terms": "Términos y Condiciones",
|
||||
@@ -2032,6 +2033,35 @@
|
||||
"rank": "Rango",
|
||||
"leader": "Líder",
|
||||
"noEquipment": "Sin equipo visible.",
|
||||
"backToArmory": "Volver a la armería"
|
||||
"backToArmory": "Volver a la armería",
|
||||
"stats": "Estadísticas",
|
||||
"statsPending": "Las estadísticas aparecerán cuando el personaje vuelva a conectarse.",
|
||||
"professions": "Profesiones",
|
||||
"noProfessions": "Sin profesiones",
|
||||
"strength": "Fuerza",
|
||||
"agility": "Agilidad",
|
||||
"stamina": "Aguante",
|
||||
"intellect": "Intelecto",
|
||||
"armor": "Armadura",
|
||||
"attackPower": "Poder de ataque",
|
||||
"rangedAttackPower": "Poder a distancia",
|
||||
"spellPower": "Poder con hechizos",
|
||||
"critChance": "Prob. crítico",
|
||||
"spellCrit": "Crítico de hechizos",
|
||||
"dodge": "Esquivar",
|
||||
"parry": "Parada",
|
||||
"block": "Bloqueo",
|
||||
"resilience": "Temple",
|
||||
"health": "Salud",
|
||||
"mana": "Maná",
|
||||
"achievementPoints": "Puntos de logros",
|
||||
"talents": "Talentos",
|
||||
"noTalents": "Sin talentos",
|
||||
"specialization": "Especialización",
|
||||
"recentActivity": "Actividad reciente",
|
||||
"earned": "Logrado",
|
||||
"viewFirstActivity": "Ver primera actividad",
|
||||
"achSearch": "Buscar logro por nombre o ID…",
|
||||
"noAchievements": "No hay logros"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@ export default function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
// Aplica a todo salvo /api, estáticos de Next e ipas con extensión.
|
||||
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
|
||||
matcher: ['/((?!api|modelviewer|_next|_vercel|.*\\..*).*)'],
|
||||
}
|
||||
|
||||
@@ -9,13 +9,8 @@ const nextConfig: NextConfig = {
|
||||
// normalización la hace el middleware con lógica estricta de pathname.
|
||||
skipTrailingSlashRedirect: true,
|
||||
|
||||
// Proxy same-origin del visor de modelos de wowhead. El contenido de
|
||||
// wow.zamimg.com/modelviewer no envía cabeceras CORS, así que el navegador
|
||||
// bloquea el visor 3D si se pide cross-origin. Sirviéndolo bajo nuestro dominio
|
||||
// (/modelviewer/*) es same-origin y no hay bloqueo. Ver components/ArmoryModel3D.
|
||||
async rewrites() {
|
||||
return [{ source: '/modelviewer/:path*', destination: 'https://wow.zamimg.com/modelviewer/:path*' }]
|
||||
},
|
||||
// El proxy del visor de modelos (/modelviewer/*) lo hace ahora un route handler
|
||||
// con caché en disco: app/modelviewer/[...path]/route.ts. Ver ese fichero.
|
||||
}
|
||||
|
||||
export default withNextIntl(nextConfig)
|
||||
|
||||
Reference in New Issue
Block a user