From 3af7f5e804f0a82bd102ef5312738c85b4c2e978 Mon Sep 17 00:00:00 2001 From: adevopg Date: Thu, 16 Jul 2026 21:07:39 +0000 Subject: [PATCH] =?UTF-8?q?Armer=C3=ADa:=20mu=C3=B1eco=20de=20papel,=20tal?= =?UTF-8?q?entos,=20actividad=20y=20cach=C3=A9=20del=20visor=203D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 5 + .../[locale]/armory/character/[guid]/page.tsx | 253 +++++++-- web-next/app/api/armory/activity/route.ts | 19 + web-next/app/armory.css | 241 +++++++++ web-next/app/modelviewer/[...path]/route.ts | 113 ++++ web-next/components/ArmoryActivity.tsx | 155 ++++++ web-next/components/ArmoryModel3D.tsx | 150 ++++-- web-next/components/ArmoryPaperdoll.tsx | 186 +++++++ web-next/components/ArmoryTalents.tsx | 82 +++ web-next/lib/armory.ts | 499 +++++++++++++++++- web-next/lib/data/talent-layout.json | 1 + web-next/lib/data/talent-tabs.json | 1 + web-next/lib/wow-data.ts | 97 ++++ web-next/lib/wowhead.ts | 12 +- web-next/messages/en.json | 34 +- web-next/messages/es.json | 34 +- web-next/middleware.ts | 2 +- web-next/next.config.ts | 9 +- 18 files changed, 1788 insertions(+), 105 deletions(-) create mode 100644 web-next/app/api/armory/activity/route.ts create mode 100644 web-next/app/modelviewer/[...path]/route.ts create mode 100644 web-next/components/ArmoryActivity.tsx create mode 100644 web-next/components/ArmoryPaperdoll.tsx create mode 100644 web-next/components/ArmoryTalents.tsx create mode 100644 web-next/lib/data/talent-layout.json create mode 100644 web-next/lib/data/talent-tabs.json diff --git a/.gitignore b/.gitignore index 89ba558..dc79eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/web-next/app/[locale]/armory/character/[guid]/page.tsx b/web-next/app/[locale]/armory/character/[guid]/page.tsx index 936faab..9cf8cb1 100644 --- a/web-next/app/[locale]/armory/character/[guid]/page.tsx +++ b/web-next/app/[locale]/armory/character/[guid]/page.tsx @@ -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 (
-

← {t('backToArmory')}

-
- {/* Columna izquierda: identidad + modelo 3D */} -
+
+

{character.name} + {avgItemLevel > 0 ? ( + + {' '} + ({t('itemLevel')} {avgItemLevel}) + + ) : null}

{t('level')} {character.level} · {raceName(character.race, locale)} ·{' '} @@ -65,52 +121,133 @@ export default async function CharacterPage({ <{character.guildName}> )} + + {achievementPoints.toLocaleString(locale)} +
-
- {/* Columna derecha: equipo */} -
-

{t('equipment')}

- {equipment.length === 0 ? ( -

{t('noEquipment')}

- ) : ( -
- {EQUIP_SLOTS.map(({ slot, es, en }) => { - const item = bySlot.get(slot) - const slotName = locale === 'en' ? en : es - if (!item) { - return ( -
- {slotName} + + + {specs.primary && ( +
+

{t('specialization')}

+
+
+ +
+
{specs.primary.name}
+
{specs.primary.dist.join(' / ')}
+
+
+
+ {specs.secondary ? ( + <> + +
+
{specs.secondary.name}
+
{specs.secondary.dist.join(' / ')}
- ) - } - return ( -
- - {item.name} - - - {slotName} - {item.itemLevel ? ` · ${item.itemLevel}` : ''} + + ) : ( + N/A + )} +
+
+
+ )} + + {talents && talents.total > 0 && ( +
+

{t('talents')}

+ + +
+ )} + + {activityData.entries.length > 0 && ( +
+

{t('recentActivity')}

+ +
+ )} + +
+
+

{t('stats')}

+ {stats ? ( +
+ {statRows.map((r) => ( +
+ {r.label} + {r.value} +
+ ))} +
+ ) : ( +

{t('statsPending')}

+ )} +
+ +
+

{t('professions')}

+ {professions.length ? ( +
+ {professions.map((p) => ( +
+ + {p.name} + {p.rank ? {p.rank} : null} + + + {p.value} + / {p.max}
- ) - })} -
- )} + ))} +
+ ) : ( +

{t('noProfessions')}

+ )} +
diff --git a/web-next/app/api/armory/activity/route.ts b/web-next/app/api/armory/activity/route.ts new file mode 100644 index 0000000..2c9e190 --- /dev/null +++ b/web-next/app/api/armory/activity/route.ts @@ -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 }) +} diff --git a/web-next/app/armory.css b/web-next/app/armory.css index 0f534df..da8b52b 100644 --- a/web-next/app/armory.css +++ b/web-next/app/armory.css @@ -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 , 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 ; sin su CSS + se repite en mosaico (visible en la vista lista). Ponemos nuestros propios iconos + en un 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; } diff --git a/web-next/app/modelviewer/[...path]/route.ts b/web-next/app/modelviewer/[...path]/route.ts new file mode 100644 index 0000000..4b997e1 --- /dev/null +++ b/web-next/app/modelviewer/[...path]/route.ts @@ -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//...), 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 = { + '.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 { + 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) }) +} diff --git a/web-next/components/ArmoryActivity.tsx b/web-next/components/ArmoryActivity.tsx new file mode 100644 index 0000000..7700712 --- /dev/null +++ b/web-next/components/ArmoryActivity.tsx @@ -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(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 ( + <> + + setQ(e.target.value)} + placeholder={labels.search} + aria-label={labels.search} + /> + + {pages > 1 && ( +
+ + {from > 1 && ( + <> + + {from > 2 && } + + )} + {nums.map((p) => ( + + ))} + {to < pages && ( + <> + {to < pages - 1 && } + + + )} + +
+ )} + + ) +} diff --git a/web-next/components/ArmoryModel3D.tsx b/web-next/components/ArmoryModel3D.tsx index b9b8780..07205cd 100644 --- a/web-next/components/ArmoryModel3D.tsx +++ b/web-next/components/ArmoryModel3D.tsx @@ -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 } + 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 { return new Promise((resolve, reject) => { @@ -35,15 +71,57 @@ function loadScript(src: string): Promise { }) } +function optionsById(doc: CustomizationDoc): Map { + 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(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') diff --git a/web-next/components/ArmoryPaperdoll.tsx b/web-next/components/ArmoryPaperdoll.tsx new file mode 100644 index 0000000..8d7a925 --- /dev/null +++ b/web-next/components/ArmoryPaperdoll.tsx @@ -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
+ return ( + + {it.icon ? ( + + ) : null} + + ) + } + + const ListRow = ({ item }: { item: SheetItem }) => ( + + + {item.icon ? ( + + ) : null} + + + {item.name} + {slotLabel(item.slot, locale)} + + {item.itemLevel ? {item.itemLevel} : null} + + ) + + return ( +
+ + +
+ + + + + + +
+ + {view === 'model' ? ( +
+
+ {PAPERDOLL_LEFT.map((s) => ( + + ))} +
+
+
+ +
+
+ {PAPERDOLL_WEAPONS.map((s) => ( + + ))} +
+
+ + {labels.health}: {health.toLocaleString(locale)} + + + {powerLabel}: {power.toLocaleString(locale)} + +
+
+
+ {PAPERDOLL_RIGHT.map((s) => ( + + ))} +
+
+ ) : ( +
+ {[...PAPERDOLL_LEFT, ...PAPERDOLL_RIGHT, ...PAPERDOLL_WEAPONS] + .map((s) => bySlot.get(s)) + .filter((it): it is SheetItem => !!it) + .map((it) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web-next/components/ArmoryTalents.tsx b/web-next/components/ArmoryTalents.tsx new file mode 100644 index 0000000..fc89d67 --- /dev/null +++ b/web-next/components/ArmoryTalents.tsx @@ -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 hijo porque wowhead sobrescribe el background del . + */ +export function ArmoryTalents({ + trees, + spec, + total, + maxPoints, + locale, + spellIcons, +}: { + trees: TalentTreeData[] + spec: string | null + total: number + maxPoints: number + locale: string + spellIcons: Record +}) { + return ( + + ) +} diff --git a/web-next/lib/armory.ts b/web-next/lib/armory.ts index 354a223..c2357d3 100644 --- a/web-next/lib/armory.ts +++ b/web-next/lib/armory.ts @@ -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( - '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( - `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 { + try { + const [rows] = await db(DB.characters).query( + '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 { + try { + const [rows] = await db(DB.characters).query( + '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 { + try { + const [rows] = await db(DB.characters).query( + '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 { + 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>/) + 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> { + const uniq = [...new Set(entries.filter((e) => e > 0))] + const out: Record = {} + 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 { + try { + const [rows] = await db(DB.characters).query( + '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( + `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 + +/** Árbol de talentos completo (character_talent + Talent.db2 layout). */ +export async function getCharacterTalents(guid: number, classId: number, locale: string): Promise { + try { + const [cg] = await db(DB.characters).query( + 'SELECT activeTalentGroup FROM characters WHERE guid = ?', + [guid], + ) + const group = Number(cg[0]?.activeTalentGroup ?? 0) + const [rows] = await db(DB.characters).query( + 'SELECT talentId, talentRank FROM character_talent WHERE guid = ? AND talentGroup = ?', + [guid, group], + ) + const rankById = new Map() + 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 { + 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> { + const uniq = [...new Set(spells.filter((s) => s > 0))] + const out: Record = {} + 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( + 'SELECT activeTalentGroup FROM characters WHERE guid = ?', + [guid], + ) + const active = Number(cg[0]?.activeTalentGroup ?? 0) + const [rows] = await db(DB.characters).query( + '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() + 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 = { 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 { + try { + const [rows] = await db(DB.characters).query( + '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 { + try { + const [rows] = await db(DB.characters).query( + '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( + '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() + if (ids.length) { + const [nrows] = await db(DB.default).query( + `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 } + } +} \ No newline at end of file diff --git a/web-next/lib/data/talent-layout.json b/web-next/lib/data/talent-layout.json new file mode 100644 index 0000000..60aa620 --- /dev/null +++ b/web-next/lib/data/talent-layout.json @@ -0,0 +1 @@ +{"23":{"tab":41,"tier":2,"col":3,"max":2,"spell":11083},"24":{"tab":41,"tier":3,"col":1,"max":2,"spell":11094},"25":{"tab":41,"tier":3,"col":0,"max":3,"spell":11095},"26":{"tab":41,"tier":0,"col":2,"max":5,"spell":11069},"27":{"tab":41,"tier":0,"col":0,"max":2,"spell":11078},"28":{"tab":41,"tier":2,"col":0,"max":2,"spell":11100},"29":{"tab":41,"tier":2,"col":2,"max":1,"spell":11366},"30":{"tab":41,"tier":2,"col":1,"max":3,"spell":11103},"31":{"tab":41,"tier":1,"col":2,"max":3,"spell":11108},"32":{"tab":41,"tier":4,"col":2,"max":1,"spell":11113},"33":{"tab":41,"tier":4,"col":1,"max":3,"spell":11115},"34":{"tab":41,"tier":1,"col":0,"max":5,"spell":11119},"35":{"tab":41,"tier":5,"col":2,"max":5,"spell":11124},"36":{"tab":41,"tier":6,"col":1,"max":1,"spell":11129},"37":{"tab":61,"tier":0,"col":1,"max":5,"spell":11070},"38":{"tab":61,"tier":0,"col":0,"max":3,"spell":11071},"61":{"tab":61,"tier":2,"col":0,"max":3,"spell":11151},"62":{"tab":61,"tier":0,"col":2,"max":3,"spell":31670},"63":{"tab":61,"tier":2,"col":2,"max":3,"spell":11185},"64":{"tab":61,"tier":4,"col":2,"max":3,"spell":11190},"65":{"tab":61,"tier":1,"col":3,"max":3,"spell":11175},"66":{"tab":61,"tier":3,"col":1,"max":3,"spell":11160},"67":{"tab":61,"tier":3,"col":2,"max":3,"spell":11170},"68":{"tab":61,"tier":5,"col":2,"max":3,"spell":11180},"69":{"tab":61,"tier":2,"col":1,"max":1,"spell":12472},"70":{"tab":61,"tier":1,"col":1,"max":2,"spell":11189},"71":{"tab":61,"tier":6,"col":1,"max":1,"spell":11426},"72":{"tab":61,"tier":4,"col":1,"max":1,"spell":11958},"73":{"tab":61,"tier":1,"col":0,"max":3,"spell":11207},"74":{"tab":81,"tier":0,"col":0,"max":2,"spell":11210},"75":{"tab":81,"tier":1,"col":2,"max":5,"spell":11213},"76":{"tab":81,"tier":0,"col":1,"max":3,"spell":11222},"77":{"tab":81,"tier":4,"col":3,"max":5,"spell":11232},"80":{"tab":81,"tier":0,"col":2,"max":5,"spell":11237},"81":{"tab":81,"tier":2,"col":1,"max":3,"spell":11242},"82":{"tab":81,"tier":2,"col":0,"max":2,"spell":11247},"83":{"tab":81,"tier":3,"col":0,"max":2,"spell":11252},"85":{"tab":81,"tier":1,"col":0,"max":3,"spell":28574},"86":{"tab":81,"tier":4,"col":1,"max":1,"spell":12043},"87":{"tab":81,"tier":6,"col":1,"max":1,"spell":12042},"88":{"tab":81,"tier":3,"col":1,"max":2,"spell":11255},"121":{"tab":161,"tier":2,"col":3,"max":3,"spell":12834},"123":{"tab":161,"tier":4,"col":3,"max":5,"spell":12281},"124":{"tab":161,"tier":0,"col":0,"max":3,"spell":12282},"125":{"tab":161,"tier":4,"col":2,"max":5,"spell":12284},"126":{"tab":161,"tier":1,"col":0,"max":2,"spell":12285},"127":{"tab":161,"tier":0,"col":2,"max":2,"spell":12286},"128":{"tab":161,"tier":1,"col":2,"max":3,"spell":12295},"129":{"tab":161,"tier":5,"col":2,"max":3,"spell":12289},"130":{"tab":161,"tier":0,"col":1,"max":5,"spell":16462},"131":{"tab":161,"tier":2,"col":0,"max":2,"spell":12290},"132":{"tab":161,"tier":4,"col":0,"max":5,"spell":12700},"133":{"tab":161,"tier":4,"col":1,"max":1,"spell":12328},"134":{"tab":161,"tier":5,"col":0,"max":2,"spell":20504},"135":{"tab":161,"tier":6,"col":1,"max":1,"spell":12294},"136":{"tab":161,"tier":3,"col":1,"max":3,"spell":12163},"137":{"tab":161,"tier":2,"col":1,"max":1,"spell":12296},"138":{"tab":163,"tier":1,"col":2,"max":5,"spell":12297},"140":{"tab":163,"tier":2,"col":3,"max":5,"spell":12299},"141":{"tab":163,"tier":0,"col":2,"max":3,"spell":12287},"142":{"tab":163,"tier":0,"col":0,"max":2,"spell":12301},"144":{"tab":163,"tier":1,"col":1,"max":3,"spell":50685},"146":{"tab":163,"tier":3,"col":2,"max":3,"spell":12308},"147":{"tab":163,"tier":2,"col":1,"max":2,"spell":12797},"148":{"tab":163,"tier":6,"col":1,"max":1,"spell":50720},"149":{"tab":163,"tier":4,"col":2,"max":2,"spell":12311},"150":{"tab":163,"tier":4,"col":0,"max":2,"spell":12312},"151":{"tab":163,"tier":3,"col":1,"max":2,"spell":12313},"152":{"tab":163,"tier":4,"col":1,"max":1,"spell":12809},"153":{"tab":163,"tier":2,"col":0,"max":1,"spell":12975},"154":{"tab":164,"tier":2,"col":3,"max":5,"spell":12318},"155":{"tab":164,"tier":3,"col":2,"max":5,"spell":12317},"156":{"tab":164,"tier":5,"col":2,"max":5,"spell":12319},"157":{"tab":164,"tier":0,"col":2,"max":5,"spell":12320},"158":{"tab":164,"tier":0,"col":1,"max":2,"spell":12321},"159":{"tab":164,"tier":1,"col":2,"max":5,"spell":12322},"160":{"tab":164,"tier":2,"col":1,"max":1,"spell":12323},"161":{"tab":164,"tier":1,"col":1,"max":5,"spell":12324},"165":{"tab":164,"tier":4,"col":1,"max":1,"spell":12292},"166":{"tab":164,"tier":2,"col":0,"max":3,"spell":12329},"167":{"tab":164,"tier":6,"col":1,"max":1,"spell":23881},"181":{"tab":181,"tier":1,"col":3,"max":5,"spell":13705},"182":{"tab":181,"tier":2,"col":2,"max":5,"spell":13706},"184":{"tab":181,"tier":4,"col":0,"max":5,"spell":13709},"186":{"tab":181,"tier":3,"col":2,"max":3,"spell":13712},"187":{"tab":181,"tier":1,"col":1,"max":3,"spell":13713},"201":{"tab":181,"tier":0,"col":1,"max":2,"spell":13732},"203":{"tab":181,"tier":0,"col":0,"max":3,"spell":13741},"204":{"tab":181,"tier":2,"col":0,"max":2,"spell":13742},"205":{"tab":181,"tier":6,"col":1,"max":1,"spell":13750},"206":{"tab":181,"tier":3,"col":0,"max":2,"spell":13754},"221":{"tab":181,"tier":0,"col":2,"max":5,"spell":13715},"222":{"tab":181,"tier":3,"col":1,"max":2,"spell":13743},"223":{"tab":181,"tier":4,"col":1,"max":1,"spell":13877},"241":{"tab":183,"tier":0,"col":1,"max":3,"spell":13958},"242":{"tab":181,"tier":4,"col":2,"max":5,"spell":13960},"244":{"tab":183,"tier":1,"col":2,"max":3,"spell":13975},"245":{"tab":183,"tier":3,"col":1,"max":3,"spell":13976},"246":{"tab":183,"tier":3,"col":0,"max":3,"spell":13983},"247":{"tab":183,"tier":2,"col":0,"max":2,"spell":13981},"261":{"tab":183,"tier":0,"col":2,"max":2,"spell":14057},"262":{"tab":183,"tier":1,"col":1,"max":2,"spell":14076},"263":{"tab":183,"tier":3,"col":2,"max":2,"spell":14079},"265":{"tab":183,"tier":4,"col":2,"max":2,"spell":14082},"268":{"tab":182,"tier":3,"col":2,"max":5,"spell":14113},"269":{"tab":182,"tier":2,"col":2,"max":5,"spell":14128},"270":{"tab":182,"tier":0,"col":2,"max":5,"spell":14138},"272":{"tab":182,"tier":0,"col":1,"max":2,"spell":14144},"273":{"tab":182,"tier":1,"col":0,"max":3,"spell":14156},"274":{"tab":182,"tier":5,"col":2,"max":2,"spell":14158},"276":{"tab":182,"tier":0,"col":0,"max":3,"spell":14162},"277":{"tab":182,"tier":1,"col":3,"max":3,"spell":13733},"278":{"tab":182,"tier":2,"col":1,"max":2,"spell":14168},"279":{"tab":182,"tier":4,"col":2,"max":3,"spell":14174},"280":{"tab":182,"tier":4,"col":1,"max":1,"spell":14177},"281":{"tab":182,"tier":6,"col":1,"max":1,"spell":58426},"283":{"tab":182,"tier":5,"col":1,"max":5,"spell":14186},"284":{"tab":183,"tier":4,"col":1,"max":1,"spell":14185},"301":{"tab":181,"tier":2,"col":1,"max":1,"spell":14251},"303":{"tab":183,"tier":2,"col":1,"max":1,"spell":14278},"321":{"tab":201,"tier":1,"col":3,"max":2,"spell":14531},"322":{"tab":201,"tier":6,"col":1,"max":1,"spell":10060},"341":{"tab":201,"tier":3,"col":1,"max":3,"spell":14520},"342":{"tab":201,"tier":0,"col":1,"max":5,"spell":14522},"343":{"tab":201,"tier":2,"col":2,"max":3,"spell":14748},"344":{"tab":201,"tier":1,"col":2,"max":2,"spell":14749},"346":{"tab":201,"tier":1,"col":1,"max":3,"spell":14747},"347":{"tab":201,"tier":2,"col":0,"max":3,"spell":14521},"348":{"tab":201,"tier":2,"col":1,"max":1,"spell":14751},"350":{"tab":201,"tier":3,"col":3,"max":2,"spell":14750},"351":{"tab":201,"tier":4,"col":2,"max":1,"spell":63574},"352":{"tab":201,"tier":1,"col":0,"max":3,"spell":14523},"361":{"tab":202,"tier":2,"col":3,"max":3,"spell":14892},"381":{"tab":183,"tier":6,"col":1,"max":1,"spell":14183},"382":{"tab":182,"tier":2,"col":0,"max":1,"spell":14983},"401":{"tab":202,"tier":0,"col":2,"max":5,"spell":14889},"402":{"tab":202,"tier":4,"col":2,"max":5,"spell":14901},"403":{"tab":202,"tier":3,"col":2,"max":2,"spell":14909},"404":{"tab":202,"tier":5,"col":2,"max":5,"spell":14898},"406":{"tab":202,"tier":0,"col":1,"max":3,"spell":14908},"408":{"tab":202,"tier":3,"col":1,"max":3,"spell":14912},"410":{"tab":202,"tier":0,"col":0,"max":2,"spell":14913},"411":{"tab":202,"tier":1,"col":1,"max":5,"spell":27900},"413":{"tab":202,"tier":4,"col":0,"max":2,"spell":14911},"421":{"tab":81,"tier":5,"col":1,"max":3,"spell":15058},"442":{"tab":202,"tier":2,"col":0,"max":1,"spell":19236},"461":{"tab":203,"tier":3,"col":3,"max":3,"spell":15257},"462":{"tab":203,"tier":0,"col":2,"max":5,"spell":15259},"463":{"tab":203,"tier":1,"col":2,"max":3,"spell":15260},"465":{"tab":203,"tier":0,"col":0,"max":3,"spell":15270},"466":{"tab":203,"tier":1,"col":0,"max":3,"spell":15318},"481":{"tab":203,"tier":2,"col":1,"max":5,"spell":15273},"482":{"tab":203,"tier":1,"col":1,"max":2,"spell":15275},"483":{"tab":203,"tier":3,"col":1,"max":2,"spell":15274},"484":{"tab":203,"tier":4,"col":1,"max":1,"spell":15286},"501":{"tab":203,"tier":2,"col":2,"max":1,"spell":15407},"521":{"tab":203,"tier":6,"col":1,"max":1,"spell":15473},"541":{"tab":203,"tier":4,"col":0,"max":1,"spell":15487},"542":{"tab":203,"tier":2,"col":0,"max":2,"spell":15392},"561":{"tab":261,"tier":1,"col":0,"max":3,"spell":16038},"562":{"tab":261,"tier":4,"col":1,"max":1,"spell":16041},"563":{"tab":261,"tier":0,"col":2,"max":5,"spell":16035},"564":{"tab":261,"tier":0,"col":1,"max":5,"spell":16039},"565":{"tab":261,"tier":2,"col":2,"max":5,"spell":16089},"567":{"tab":261,"tier":3,"col":0,"max":2,"spell":16086},"573":{"tab":261,"tier":6,"col":1,"max":1,"spell":16166},"574":{"tab":261,"tier":2,"col":1,"max":1,"spell":16164},"575":{"tab":261,"tier":2,"col":0,"max":5,"spell":16040},"581":{"tab":262,"tier":2,"col":3,"max":3,"spell":16176},"582":{"tab":262,"tier":2,"col":2,"max":1,"spell":55198},"583":{"tab":262,"tier":2,"col":0,"max":3,"spell":16180},"586":{"tab":262,"tier":0,"col":1,"max":5,"spell":16182},"587":{"tab":262,"tier":2,"col":1,"max":3,"spell":16181},"588":{"tab":262,"tier":3,"col":1,"max":3,"spell":16187},"589":{"tab":262,"tier":1,"col":0,"max":2,"spell":16184},"590":{"tab":262,"tier":6,"col":1,"max":1,"spell":16190},"591":{"tab":262,"tier":4,"col":2,"max":1,"spell":16188},"592":{"tab":262,"tier":5,"col":2,"max":5,"spell":16178},"593":{"tab":262,"tier":1,"col":2,"max":5,"spell":16179},"594":{"tab":262,"tier":3,"col":2,"max":5,"spell":16194},"595":{"tab":262,"tier":0,"col":2,"max":5,"spell":16173},"601":{"tab":263,"tier":2,"col":3,"max":3,"spell":16254},"602":{"tab":263,"tier":3,"col":1,"max":5,"spell":16256},"605":{"tab":263,"tier":1,"col":2,"max":2,"spell":16262},"607":{"tab":263,"tier":1,"col":3,"max":3,"spell":16261},"609":{"tab":263,"tier":1,"col":0,"max":2,"spell":16258},"610":{"tab":263,"tier":0,"col":0,"max":3,"spell":16259},"611":{"tab":263,"tier":2,"col":0,"max":3,"spell":16266},"613":{"tab":263,"tier":1,"col":1,"max":5,"spell":16255},"614":{"tab":263,"tier":0,"col":2,"max":5,"spell":17485},"615":{"tab":263,"tier":3,"col":2,"max":5,"spell":16252},"616":{"tab":263,"tier":4,"col":1,"max":1,"spell":16268},"617":{"tab":263,"tier":2,"col":2,"max":1,"spell":43338},"641":{"tab":161,"tier":1,"col":1,"max":3,"spell":12300},"661":{"tab":164,"tier":2,"col":2,"max":3,"spell":16487},"662":{"tab":161,"tier":2,"col":2,"max":2,"spell":16493},"681":{"tab":183,"tier":4,"col":3,"max":1,"spell":16511},"682":{"tab":182,"tier":3,"col":1,"max":3,"spell":16513},"702":{"tab":163,"tier":5,"col":2,"max":5,"spell":16538},"721":{"tab":261,"tier":5,"col":2,"max":5,"spell":16578},"741":{"tab":61,"tier":3,"col":0,"max":2,"spell":16757},"762":{"tab":283,"tier":0,"col":1,"max":5,"spell":16814},"763":{"tab":283,"tier":1,"col":3,"max":2,"spell":16821},"764":{"tab":283,"tier":2,"col":3,"max":2,"spell":16819},"782":{"tab":283,"tier":2,"col":0,"max":3,"spell":16836},"783":{"tab":283,"tier":1,"col":0,"max":3,"spell":16845},"784":{"tab":283,"tier":3,"col":2,"max":3,"spell":16850},"788":{"tab":283,"tier":4,"col":1,"max":1,"spell":5570},"789":{"tab":283,"tier":2,"col":1,"max":3,"spell":16880},"790":{"tab":283,"tier":5,"col":1,"max":3,"spell":16896},"792":{"tab":283,"tier":3,"col":1,"max":5,"spell":16909},"793":{"tab":283,"tier":6,"col":1,"max":1,"spell":24858},"794":{"tab":281,"tier":1,"col":2,"max":3,"spell":16929},"795":{"tab":281,"tier":0,"col":2,"max":5,"spell":16858},"796":{"tab":281,"tier":0,"col":1,"max":5,"spell":16934},"797":{"tab":281,"tier":4,"col":0,"max":2,"spell":16940},"798":{"tab":281,"tier":2,"col":2,"max":3,"spell":16942},"799":{"tab":281,"tier":1,"col":0,"max":3,"spell":16947},"801":{"tab":281,"tier":3,"col":2,"max":2,"spell":37116},"802":{"tab":281,"tier":3,"col":0,"max":2,"spell":16966},"803":{"tab":281,"tier":3,"col":1,"max":3,"spell":16972},"804":{"tab":281,"tier":4,"col":2,"max":1,"spell":49377},"805":{"tab":281,"tier":1,"col":1,"max":2,"spell":16998},"807":{"tab":281,"tier":2,"col":0,"max":2,"spell":17002},"808":{"tab":281,"tier":5,"col":1,"max":5,"spell":17003},"809":{"tab":281,"tier":6,"col":1,"max":1,"spell":17007},"821":{"tab":282,"tier":0,"col":0,"max":2,"spell":17050},"822":{"tab":282,"tier":0,"col":2,"max":5,"spell":17056},"823":{"tab":282,"tier":0,"col":1,"max":3,"spell":17063},"824":{"tab":282,"tier":1,"col":0,"max":5,"spell":17069},"825":{"tab":282,"tier":5,"col":2,"max":5,"spell":17074},"826":{"tab":282,"tier":1,"col":2,"max":3,"spell":16833},"827":{"tab":282,"tier":2,"col":1,"max":1,"spell":16864},"828":{"tab":282,"tier":4,"col":1,"max":5,"spell":17104},"829":{"tab":282,"tier":2,"col":0,"max":3,"spell":17106},"830":{"tab":282,"tier":3,"col":2,"max":3,"spell":17111},"831":{"tab":282,"tier":4,"col":0,"max":1,"spell":17116},"841":{"tab":282,"tier":1,"col":1,"max":3,"spell":17118},"842":{"tab":282,"tier":4,"col":3,"max":2,"spell":17123},"843":{"tab":282,"tier":3,"col":1,"max":5,"spell":24968},"844":{"tab":282,"tier":6,"col":1,"max":1,"spell":18562},"881":{"tab":203,"tier":3,"col":2,"max":2,"spell":17322},"901":{"tab":263,"tier":6,"col":2,"max":1,"spell":17364},"941":{"tab":301,"tier":1,"col":2,"max":3,"spell":17778},"943":{"tab":301,"tier":0,"col":2,"max":5,"spell":17788},"944":{"tab":301,"tier":0,"col":1,"max":5,"spell":17793},"961":{"tab":301,"tier":4,"col":1,"max":3,"spell":17815},"963":{"tab":301,"tier":2,"col":1,"max":1,"spell":17877},"964":{"tab":301,"tier":3,"col":1,"max":2,"spell":17917},"965":{"tab":301,"tier":3,"col":3,"max":3,"spell":17927},"966":{"tab":301,"tier":5,"col":2,"max":5,"spell":17954},"967":{"tab":301,"tier":2,"col":2,"max":5,"spell":17959},"968":{"tab":301,"tier":6,"col":1,"max":1,"spell":17962},"981":{"tab":301,"tier":4,"col":2,"max":1,"spell":18130},"982":{"tab":301,"tier":1,"col":0,"max":2,"spell":18119},"983":{"tab":301,"tier":2,"col":0,"max":2,"spell":18126},"985":{"tab":301,"tier":3,"col":0,"max":2,"spell":18135},"986":{"tab":301,"tier":6,"col":3,"max":3,"spell":18096},"1001":{"tab":302,"tier":2,"col":1,"max":3,"spell":17783},"1002":{"tab":302,"tier":3,"col":1,"max":2,"spell":18094},"1003":{"tab":302,"tier":0,"col":2,"max":5,"spell":17810},"1004":{"tab":302,"tier":1,"col":3,"max":2,"spell":17804},"1005":{"tab":302,"tier":0,"col":1,"max":3,"spell":18174},"1006":{"tab":302,"tier":1,"col":0,"max":2,"spell":18179},"1007":{"tab":302,"tier":1,"col":2,"max":2,"spell":18182},"1021":{"tab":302,"tier":3,"col":0,"max":2,"spell":18218},"1022":{"tab":302,"tier":6,"col":2,"max":1,"spell":18220},"1041":{"tab":302,"tier":4,"col":1,"max":1,"spell":63108},"1042":{"tab":302,"tier":5,"col":1,"max":5,"spell":18271},"1061":{"tab":302,"tier":2,"col":2,"max":1,"spell":18288},"1081":{"tab":302,"tier":4,"col":2,"max":1,"spell":18223},"1101":{"tab":302,"tier":1,"col":1,"max":2,"spell":18213},"1122":{"tab":181,"tier":3,"col":3,"max":5,"spell":18427},"1123":{"tab":183,"tier":2,"col":2,"max":3,"spell":14171},"1141":{"tab":41,"tier":0,"col":1,"max":3,"spell":18459},"1142":{"tab":81,"tier":3,"col":2,"max":3,"spell":18462},"1162":{"tab":281,"tier":2,"col":1,"max":1,"spell":61336},"1181":{"tab":202,"tier":1,"col":2,"max":5,"spell":18530},"1201":{"tab":201,"tier":4,"col":1,"max":5,"spell":18551},"1202":{"tab":201,"tier":9,"col":1,"max":5,"spell":52795},"1221":{"tab":303,"tier":0,"col":0,"max":2,"spell":18692},"1222":{"tab":303,"tier":0,"col":1,"max":3,"spell":18694},"1223":{"tab":303,"tier":0,"col":2,"max":3,"spell":18697},"1224":{"tab":303,"tier":1,"col":0,"max":2,"spell":18703},"1225":{"tab":303,"tier":1,"col":1,"max":3,"spell":18705},"1226":{"tab":303,"tier":2,"col":2,"max":1,"spell":18708},"1227":{"tab":303,"tier":3,"col":2,"max":2,"spell":18709},"1242":{"tab":303,"tier":1,"col":2,"max":3,"spell":18731},"1243":{"tab":303,"tier":2,"col":0,"max":3,"spell":18754},"1244":{"tab":303,"tier":5,"col":1,"max":5,"spell":23785},"1261":{"tab":303,"tier":4,"col":2,"max":2,"spell":18767},"1262":{"tab":303,"tier":3,"col":1,"max":5,"spell":18769},"1263":{"tab":303,"tier":6,"col":2,"max":3,"spell":35691},"1281":{"tab":303,"tier":4,"col":0,"max":1,"spell":30326},"1282":{"tab":303,"tier":2,"col":1,"max":1,"spell":19028},"1283":{"tab":303,"tier":5,"col":2,"max":3,"spell":47245},"1284":{"tab":302,"tier":0,"col":0,"max":2,"spell":18827},"1303":{"tab":362,"tier":5,"col":0,"max":5,"spell":19168},"1304":{"tab":362,"tier":1,"col":1,"max":3,"spell":19184},"1305":{"tab":362,"tier":1,"col":2,"max":3,"spell":19376},"1306":{"tab":362,"tier":3,"col":3,"max":3,"spell":56342},"1309":{"tab":362,"tier":2,"col":3,"max":2,"spell":19286},"1310":{"tab":362,"tier":1,"col":0,"max":3,"spell":19290},"1311":{"tab":362,"tier":2,"col":2,"max":3,"spell":19295},"1312":{"tab":362,"tier":4,"col":2,"max":1,"spell":19306},"1321":{"tab":362,"tier":4,"col":1,"max":3,"spell":19370},"1322":{"tab":362,"tier":8,"col":1,"max":1,"spell":3674},"1325":{"tab":362,"tier":6,"col":1,"max":1,"spell":19386},"1341":{"tab":363,"tier":0,"col":0,"max":2,"spell":19407},"1342":{"tab":363,"tier":3,"col":2,"max":5,"spell":19416},"1343":{"tab":363,"tier":1,"col":1,"max":3,"spell":19421},"1344":{"tab":363,"tier":0,"col":2,"max":5,"spell":19426},"1345":{"tab":363,"tier":2,"col":2,"max":1,"spell":19434},"1346":{"tab":363,"tier":2,"col":1,"max":3,"spell":19454},"1347":{"tab":363,"tier":4,"col":2,"max":3,"spell":19461},"1348":{"tab":363,"tier":3,"col":1,"max":3,"spell":19464},"1349":{"tab":363,"tier":1,"col":2,"max":5,"spell":19485},"1351":{"tab":363,"tier":4,"col":0,"max":2,"spell":35100},"1353":{"tab":363,"tier":4,"col":1,"max":1,"spell":23989},"1361":{"tab":363,"tier":6,"col":1,"max":1,"spell":19506},"1362":{"tab":363,"tier":5,"col":3,"max":3,"spell":19507},"1381":{"tab":361,"tier":1,"col":1,"max":3,"spell":19549},"1382":{"tab":361,"tier":0,"col":1,"max":5,"spell":19552},"1384":{"tab":361,"tier":2,"col":0,"max":2,"spell":19559},"1385":{"tab":361,"tier":3,"col":1,"max":2,"spell":19572},"1386":{"tab":361,"tier":6,"col":1,"max":1,"spell":19574},"1387":{"tab":361,"tier":4,"col":1,"max":1,"spell":19577},"1388":{"tab":361,"tier":4,"col":0,"max":2,"spell":19578},"1389":{"tab":361,"tier":0,"col":2,"max":5,"spell":19583},"1390":{"tab":361,"tier":4,"col":3,"max":2,"spell":19590},"1393":{"tab":361,"tier":3,"col":2,"max":5,"spell":19598},"1395":{"tab":361,"tier":1,"col":2,"max":3,"spell":19609},"1396":{"tab":361,"tier":2,"col":2,"max":5,"spell":19616},"1397":{"tab":361,"tier":5,"col":2,"max":5,"spell":19621},"1401":{"tab":381,"tier":1,"col":2,"max":2,"spell":20042},"1402":{"tab":381,"tier":5,"col":1,"max":3,"spell":20049},"1403":{"tab":381,"tier":0,"col":1,"max":5,"spell":20060},"1407":{"tab":381,"tier":0,"col":2,"max":5,"spell":20101},"1410":{"tab":381,"tier":4,"col":0,"max":3,"spell":20111},"1411":{"tab":381,"tier":2,"col":1,"max":5,"spell":20117},"1421":{"tab":383,"tier":7,"col":0,"max":3,"spell":20127},"1422":{"tab":383,"tier":3,"col":2,"max":3,"spell":20138},"1423":{"tab":383,"tier":2,"col":2,"max":5,"spell":20143},"1425":{"tab":383,"tier":1,"col":1,"max":2,"spell":20174},"1426":{"tab":383,"tier":4,"col":2,"max":5,"spell":20177},"1429":{"tab":383,"tier":5,"col":2,"max":3,"spell":20196},"1430":{"tab":383,"tier":6,"col":1,"max":1,"spell":20925},"1431":{"tab":383,"tier":4,"col":1,"max":1,"spell":20911},"1432":{"tab":382,"tier":0,"col":1,"max":5,"spell":20205},"1433":{"tab":382,"tier":4,"col":1,"max":1,"spell":20216},"1435":{"tab":382,"tier":2,"col":0,"max":1,"spell":31821},"1441":{"tab":381,"tier":6,"col":1,"max":1,"spell":20066},"1442":{"tab":383,"tier":0,"col":1,"max":5,"spell":63646},"1443":{"tab":382,"tier":2,"col":2,"max":2,"spell":20234},"1444":{"tab":382,"tier":1,"col":0,"max":3,"spell":20237},"1446":{"tab":382,"tier":3,"col":2,"max":2,"spell":20244},"1449":{"tab":382,"tier":1,"col":1,"max":5,"spell":20257},"1450":{"tab":382,"tier":3,"col":0,"max":3,"spell":20254},"1461":{"tab":382,"tier":2,"col":1,"max":5,"spell":20210},"1463":{"tab":382,"tier":0,"col":2,"max":5,"spell":20224},"1464":{"tab":381,"tier":1,"col":1,"max":3,"spell":20335},"1465":{"tab":382,"tier":4,"col":2,"max":3,"spell":20359},"1481":{"tab":381,"tier":2,"col":2,"max":1,"spell":20375},"1501":{"tab":383,"tier":2,"col":1,"max":3,"spell":20468},"1502":{"tab":382,"tier":6,"col":1,"max":1,"spell":20473},"1521":{"tab":383,"tier":3,"col":1,"max":2,"spell":20487},"1541":{"tab":164,"tier":5,"col":0,"max":2,"spell":20500},"1542":{"tab":164,"tier":3,"col":1,"max":2,"spell":20502},"1543":{"tab":164,"tier":4,"col":2,"max":2,"spell":29888},"1561":{"tab":202,"tier":4,"col":1,"max":1,"spell":20711},"1581":{"tab":164,"tier":3,"col":0,"max":5,"spell":23584},"1601":{"tab":163,"tier":0,"col":1,"max":5,"spell":12298},"1621":{"tab":362,"tier":0,"col":2,"max":2,"spell":19159},"1622":{"tab":362,"tier":2,"col":0,"max":5,"spell":19255},"1623":{"tab":362,"tier":0,"col":0,"max":5,"spell":52783},"1624":{"tab":361,"tier":1,"col":0,"max":2,"spell":35029},"1625":{"tab":361,"tier":1,"col":3,"max":2,"spell":24443},"1627":{"tab":382,"tier":5,"col":2,"max":5,"spell":5923},"1628":{"tab":382,"tier":1,"col":2,"max":2,"spell":9453},"1629":{"tab":383,"tier":1,"col":2,"max":5,"spell":20096},"1631":{"tab":381,"tier":1,"col":0,"max":2,"spell":25956},"1632":{"tab":381,"tier":3,"col":0,"max":2,"spell":9799},"1633":{"tab":381,"tier":2,"col":0,"max":2,"spell":9452},"1634":{"tab":381,"tier":2,"col":3,"max":2,"spell":26022},"1635":{"tab":202,"tier":3,"col":0,"max":2,"spell":27789},"1636":{"tab":202,"tier":2,"col":1,"max":3,"spell":27811},"1637":{"tab":202,"tier":6,"col":1,"max":1,"spell":724},"1638":{"tab":203,"tier":4,"col":2,"max":2,"spell":27839},"1639":{"tab":41,"tier":3,"col":3,"max":3,"spell":29074},"1640":{"tab":261,"tier":1,"col":1,"max":3,"spell":28996},"1641":{"tab":261,"tier":4,"col":0,"max":2,"spell":28999},"1642":{"tab":261,"tier":3,"col":3,"max":3,"spell":29062},"1643":{"tab":263,"tier":5,"col":2,"max":3,"spell":29082},"1645":{"tab":261,"tier":1,"col":2,"max":3,"spell":30160},"1646":{"tab":262,"tier":1,"col":1,"max":3,"spell":29187},"1647":{"tab":263,"tier":4,"col":0,"max":2,"spell":29192},"1648":{"tab":262,"tier":4,"col":0,"max":3,"spell":29206},"1649":{"tab":61,"tier":1,"col":2,"max":3,"spell":29438},"1650":{"tab":81,"tier":1,"col":1,"max":2,"spell":29441},"1652":{"tab":163,"tier":6,"col":0,"max":2,"spell":29593},"1653":{"tab":163,"tier":7,"col":1,"max":3,"spell":29140},"1654":{"tab":163,"tier":2,"col":2,"max":2,"spell":29598},"1655":{"tab":164,"tier":6,"col":3,"max":2,"spell":29721},"1657":{"tab":164,"tier":4,"col":0,"max":3,"spell":29590},"1658":{"tab":164,"tier":7,"col":3,"max":5,"spell":29759},"1659":{"tab":164,"tier":8,"col":1,"max":1,"spell":29801},"1660":{"tab":163,"tier":6,"col":2,"max":3,"spell":29787},"1661":{"tab":161,"tier":8,"col":1,"max":1,"spell":29623},"1662":{"tab":161,"tier":8,"col":0,"max":3,"spell":29723},"1663":{"tab":161,"tier":6,"col":0,"max":2,"spell":29834},"1664":{"tab":161,"tier":8,"col":2,"max":2,"spell":29836},"1666":{"tab":163,"tier":8,"col":1,"max":1,"spell":20243},"1667":{"tab":302,"tier":7,"col":2,"max":3,"spell":32477},"1668":{"tab":302,"tier":7,"col":0,"max":2,"spell":30054},"1669":{"tab":302,"tier":6,"col":1,"max":5,"spell":30060},"1670":{"tab":302,"tier":8,"col":1,"max":1,"spell":30108},"1671":{"tab":303,"tier":2,"col":3,"max":3,"spell":30143},"1672":{"tab":303,"tier":8,"col":1,"max":1,"spell":30146},"1673":{"tab":303,"tier":7,"col":1,"max":5,"spell":30242},"1676":{"tab":301,"tier":8,"col":1,"max":1,"spell":30283},"1677":{"tab":301,"tier":7,"col":1,"max":5,"spell":30288},"1678":{"tab":301,"tier":6,"col":2,"max":3,"spell":30293},"1679":{"tab":301,"tier":5,"col":0,"max":3,"spell":30299},"1680":{"tab":303,"tier":6,"col":0,"max":3,"spell":30319},"1682":{"tab":261,"tier":4,"col":3,"max":3,"spell":30664},"1685":{"tab":261,"tier":5,"col":0,"max":3,"spell":30672},"1686":{"tab":261,"tier":7,"col":2,"max":3,"spell":30675},"1687":{"tab":261,"tier":8,"col":1,"max":1,"spell":30706},"1689":{"tab":263,"tier":5,"col":0,"max":3,"spell":30802},"1690":{"tab":263,"tier":6,"col":1,"max":1,"spell":30798},"1691":{"tab":263,"tier":8,"col":0,"max":3,"spell":30812},"1692":{"tab":263,"tier":6,"col":0,"max":3,"spell":30816},"1693":{"tab":263,"tier":8,"col":1,"max":1,"spell":30823},"1695":{"tab":262,"tier":4,"col":3,"max":3,"spell":30864},"1696":{"tab":262,"tier":7,"col":2,"max":3,"spell":30867},"1697":{"tab":262,"tier":7,"col":1,"max":2,"spell":30872},"1698":{"tab":262,"tier":8,"col":1,"max":1,"spell":974},"1699":{"tab":262,"tier":6,"col":0,"max":5,"spell":30881},"1700":{"tab":183,"tier":1,"col":0,"max":2,"spell":30892},"1701":{"tab":183,"tier":4,"col":0,"max":2,"spell":30894},"1702":{"tab":183,"tier":5,"col":2,"max":5,"spell":30902},"1703":{"tab":181,"tier":5,"col":1,"max":2,"spell":30919},"1705":{"tab":181,"tier":6,"col":0,"max":3,"spell":31122},"1706":{"tab":181,"tier":5,"col":2,"max":2,"spell":31124},"1707":{"tab":181,"tier":6,"col":2,"max":2,"spell":31130},"1709":{"tab":181,"tier":8,"col":1,"max":1,"spell":32601},"1711":{"tab":183,"tier":6,"col":0,"max":3,"spell":31211},"1712":{"tab":183,"tier":7,"col":1,"max":5,"spell":31216},"1713":{"tab":183,"tier":5,"col":0,"max":3,"spell":31221},"1714":{"tab":183,"tier":8,"col":1,"max":1,"spell":36554},"1715":{"tab":182,"tier":8,"col":0,"max":3,"spell":31226},"1718":{"tab":182,"tier":7,"col":2,"max":3,"spell":31234},"1719":{"tab":182,"tier":8,"col":1,"max":1,"spell":1329},"1721":{"tab":182,"tier":4,"col":0,"max":2,"spell":31208},"1722":{"tab":183,"tier":6,"col":2,"max":3,"spell":31228},"1723":{"tab":182,"tier":6,"col":2,"max":3,"spell":31380},"1724":{"tab":81,"tier":4,"col":0,"max":2,"spell":31569},"1725":{"tab":81,"tier":5,"col":2,"max":2,"spell":31571},"1726":{"tab":81,"tier":5,"col":0,"max":3,"spell":31574},"1727":{"tab":81,"tier":6,"col":0,"max":3,"spell":31579},"1728":{"tab":81,"tier":7,"col":2,"max":5,"spell":31584},"1729":{"tab":81,"tier":8,"col":1,"max":1,"spell":31589},"1730":{"tab":41,"tier":4,"col":0,"max":3,"spell":31638},"1731":{"tab":41,"tier":5,"col":0,"max":2,"spell":31641},"1732":{"tab":41,"tier":6,"col":2,"max":2,"spell":31679},"1733":{"tab":41,"tier":6,"col":0,"max":3,"spell":34293},"1734":{"tab":41,"tier":7,"col":2,"max":3,"spell":31656},"1735":{"tab":41,"tier":8,"col":1,"max":1,"spell":31661},"1736":{"tab":61,"tier":4,"col":3,"max":3,"spell":31667},"1737":{"tab":61,"tier":5,"col":0,"max":2,"spell":55091},"1738":{"tab":61,"tier":6,"col":2,"max":5,"spell":31674},"1740":{"tab":61,"tier":7,"col":1,"max":2,"spell":31682},"1741":{"tab":61,"tier":8,"col":1,"max":1,"spell":31687},"1742":{"tab":382,"tier":4,"col":0,"max":2,"spell":31822},"1743":{"tab":382,"tier":5,"col":0,"max":2,"spell":31825},"1744":{"tab":382,"tier":6,"col":2,"max":3,"spell":31828},"1745":{"tab":382,"tier":6,"col":0,"max":3,"spell":31833},"1746":{"tab":382,"tier":7,"col":2,"max":5,"spell":31837},"1747":{"tab":382,"tier":8,"col":0,"max":1,"spell":31842},"1748":{"tab":383,"tier":1,"col":0,"max":3,"spell":31844},"1750":{"tab":383,"tier":5,"col":0,"max":2,"spell":31848},"1751":{"tab":383,"tier":6,"col":2,"max":3,"spell":31850},"1753":{"tab":383,"tier":7,"col":2,"max":3,"spell":31858},"1754":{"tab":383,"tier":8,"col":1,"max":1,"spell":31935},"1755":{"tab":381,"tier":3,"col":3,"max":3,"spell":31866},"1756":{"tab":381,"tier":4,"col":2,"max":1,"spell":31869},"1757":{"tab":381,"tier":5,"col":2,"max":2,"spell":31871},"1758":{"tab":381,"tier":6,"col":2,"max":3,"spell":31876},"1759":{"tab":381,"tier":7,"col":1,"max":3,"spell":31879},"1761":{"tab":381,"tier":3,"col":2,"max":3,"spell":32043},"1762":{"tab":182,"tier":4,"col":3,"max":2,"spell":31244},"1763":{"tab":302,"tier":4,"col":0,"max":5,"spell":32385},"1764":{"tab":302,"tier":3,"col":3,"max":3,"spell":32381},"1765":{"tab":202,"tier":6,"col":2,"max":3,"spell":33142},"1766":{"tab":202,"tier":5,"col":0,"max":2,"spell":33150},"1767":{"tab":202,"tier":7,"col":1,"max":5,"spell":33158},"1768":{"tab":202,"tier":6,"col":0,"max":3,"spell":34753},"1769":{"tab":201,"tier":3,"col":0,"max":3,"spell":33167},"1771":{"tab":201,"tier":5,"col":0,"max":2,"spell":33186},"1772":{"tab":201,"tier":5,"col":2,"max":3,"spell":34908},"1773":{"tab":201,"tier":6,"col":2,"max":3,"spell":63504},"1774":{"tab":201,"tier":8,"col":1,"max":1,"spell":33206},"1777":{"tab":203,"tier":4,"col":3,"max":3,"spell":33213},"1778":{"tab":203,"tier":6,"col":2,"max":5,"spell":33221},"1779":{"tab":203,"tier":8,"col":1,"max":1,"spell":34914},"1781":{"tab":203,"tier":5,"col":0,"max":2,"spell":14910},"1782":{"tab":283,"tier":4,"col":0,"max":3,"spell":33589},"1783":{"tab":283,"tier":5,"col":2,"max":2,"spell":33592},"1784":{"tab":283,"tier":5,"col":0,"max":3,"spell":33597},"1785":{"tab":283,"tier":6,"col":3,"max":3,"spell":33600},"1786":{"tab":283,"tier":7,"col":2,"max":5,"spell":33603},"1787":{"tab":283,"tier":8,"col":2,"max":1,"spell":33831},"1788":{"tab":282,"tier":5,"col":0,"max":2,"spell":33879},"1789":{"tab":282,"tier":7,"col":1,"max":5,"spell":33886},"1790":{"tab":282,"tier":6,"col":2,"max":3,"spell":33881},"1791":{"tab":282,"tier":8,"col":1,"max":1,"spell":65139},"1792":{"tab":281,"tier":4,"col":3,"max":2,"spell":33872},"1793":{"tab":281,"tier":6,"col":3,"max":3,"spell":33851},"1794":{"tab":281,"tier":5,"col":2,"max":3,"spell":33853},"1795":{"tab":281,"tier":7,"col":2,"max":3,"spell":33859},"1796":{"tab":281,"tier":8,"col":1,"max":1,"spell":33917},"1797":{"tab":282,"tier":6,"col":0,"max":3,"spell":34151},"1798":{"tab":281,"tier":6,"col":2,"max":2,"spell":34297},"1799":{"tab":361,"tier":5,"col":0,"max":2,"spell":34453},"1800":{"tab":361,"tier":6,"col":0,"max":3,"spell":34455},"1801":{"tab":361,"tier":6,"col":2,"max":3,"spell":34462},"1802":{"tab":361,"tier":7,"col":2,"max":5,"spell":34466},"1803":{"tab":361,"tier":8,"col":1,"max":1,"spell":34692},"1804":{"tab":363,"tier":5,"col":0,"max":2,"spell":34475},"1806":{"tab":363,"tier":1,"col":0,"max":3,"spell":34482},"1807":{"tab":363,"tier":7,"col":1,"max":5,"spell":34485},"1808":{"tab":363,"tier":8,"col":1,"max":1,"spell":34490},"1809":{"tab":362,"tier":5,"col":2,"max":3,"spell":34491},"1810":{"tab":362,"tier":1,"col":3,"max":2,"spell":34494},"1811":{"tab":362,"tier":6,"col":2,"max":3,"spell":34497},"1812":{"tab":362,"tier":6,"col":0,"max":3,"spell":34500},"1813":{"tab":362,"tier":7,"col":0,"max":5,"spell":34506},"1814":{"tab":362,"tier":2,"col":1,"max":1,"spell":19503},"1815":{"tab":202,"tier":8,"col":1,"max":1,"spell":34861},"1816":{"tab":203,"tier":7,"col":2,"max":3,"spell":33191},"1817":{"tab":301,"tier":4,"col":0,"max":3,"spell":34935},"1818":{"tab":363,"tier":2,"col":0,"max":2,"spell":34950},"1819":{"tab":363,"tier":2,"col":3,"max":2,"spell":34948},"1820":{"tab":362,"tier":0,"col":1,"max":3,"spell":19498},"1821":{"tab":363,"tier":6,"col":2,"max":3,"spell":35104},"1822":{"tab":283,"tier":1,"col":1,"max":2,"spell":35363},"1823":{"tab":381,"tier":8,"col":1,"max":1,"spell":35395},"1824":{"tab":161,"tier":7,"col":1,"max":3,"spell":35446},"1825":{"tab":181,"tier":7,"col":2,"max":5,"spell":35541},"1826":{"tab":81,"tier":9,"col":2,"max":2,"spell":35578},"1827":{"tab":181,"tier":1,"col":0,"max":2,"spell":14165},"1843":{"tab":81,"tier":7,"col":1,"max":2,"spell":44378},"1844":{"tab":81,"tier":6,"col":2,"max":3,"spell":44394},"1845":{"tab":81,"tier":2,"col":2,"max":3,"spell":44397},"1846":{"tab":81,"tier":9,"col":1,"max":3,"spell":44400},"1847":{"tab":81,"tier":10,"col":1,"max":1,"spell":44425},"1848":{"tab":41,"tier":7,"col":0,"max":2,"spell":64353},"1849":{"tab":41,"tier":8,"col":0,"max":2,"spell":44442},"1850":{"tab":41,"tier":8,"col":2,"max":3,"spell":44445},"1851":{"tab":41,"tier":9,"col":1,"max":5,"spell":44449},"1852":{"tab":41,"tier":10,"col":1,"max":1,"spell":44457},"1853":{"tab":61,"tier":7,"col":2,"max":2,"spell":44543},"1854":{"tab":61,"tier":8,"col":0,"max":3,"spell":44546},"1855":{"tab":61,"tier":8,"col":2,"max":3,"spell":44557},"1856":{"tab":61,"tier":9,"col":1,"max":5,"spell":44566},"1857":{"tab":61,"tier":10,"col":1,"max":1,"spell":44572},"1858":{"tab":201,"tier":6,"col":0,"max":3,"spell":45234},"1859":{"tab":161,"tier":5,"col":3,"max":2,"spell":46854},"1860":{"tab":161,"tier":7,"col":2,"max":2,"spell":46859},"1862":{"tab":161,"tier":6,"col":2,"max":2,"spell":46865},"1863":{"tab":161,"tier":10,"col":1,"max":1,"spell":46924},"1864":{"tab":164,"tier":6,"col":0,"max":3,"spell":46908},"1865":{"tab":164,"tier":7,"col":0,"max":2,"spell":46910},"1866":{"tab":164,"tier":8,"col":2,"max":3,"spell":46913},"1867":{"tab":164,"tier":10,"col":1,"max":1,"spell":46917},"1868":{"tab":164,"tier":8,"col":0,"max":1,"spell":60970},"1870":{"tab":163,"tier":7,"col":2,"max":2,"spell":46945},"1871":{"tab":163,"tier":9,"col":1,"max":3,"spell":46951},"1872":{"tab":163,"tier":10,"col":1,"max":1,"spell":46968},"1873":{"tab":302,"tier":5,"col":0,"max":2,"spell":54037},"1875":{"tab":302,"tier":8,"col":0,"max":3,"spell":47198},"1876":{"tab":302,"tier":9,"col":1,"max":5,"spell":47201},"1878":{"tab":302,"tier":6,"col":0,"max":3,"spell":47195},"1880":{"tab":303,"tier":6,"col":1,"max":1,"spell":47193},"1882":{"tab":303,"tier":8,"col":0,"max":3,"spell":54347},"1883":{"tab":303,"tier":0,"col":3,"max":2,"spell":47230},"1884":{"tab":303,"tier":8,"col":2,"max":3,"spell":63117},"1885":{"tab":303,"tier":9,"col":1,"max":5,"spell":47236},"1886":{"tab":303,"tier":10,"col":1,"max":1,"spell":59672},"1887":{"tab":301,"tier":1,"col":1,"max":3,"spell":63349},"1888":{"tab":301,"tier":8,"col":0,"max":3,"spell":47258},"1889":{"tab":301,"tier":7,"col":2,"max":2,"spell":54117},"1890":{"tab":301,"tier":9,"col":1,"max":5,"spell":47266},"1891":{"tab":301,"tier":10,"col":1,"max":1,"spell":50796},"1893":{"tab":163,"tier":8,"col":2,"max":3,"spell":47294},"1894":{"tab":201,"tier":7,"col":2,"max":2,"spell":47507},"1895":{"tab":201,"tier":8,"col":0,"max":3,"spell":47509},"1896":{"tab":201,"tier":7,"col":1,"max":3,"spell":47535},"1897":{"tab":201,"tier":10,"col":1,"max":1,"spell":47540},"1898":{"tab":201,"tier":0,"col":2,"max":5,"spell":47586},"1901":{"tab":201,"tier":8,"col":2,"max":2,"spell":47516},"1902":{"tab":202,"tier":8,"col":0,"max":3,"spell":63534},"1903":{"tab":202,"tier":8,"col":2,"max":3,"spell":47558},"1904":{"tab":202,"tier":7,"col":2,"max":3,"spell":63730},"1905":{"tab":202,"tier":9,"col":1,"max":5,"spell":47562},"1906":{"tab":203,"tier":7,"col":0,"max":2,"spell":47569},"1907":{"tab":203,"tier":9,"col":2,"max":5,"spell":47573},"1908":{"tab":203,"tier":8,"col":0,"max":1,"spell":64044},"1909":{"tab":203,"tier":8,"col":2,"max":3,"spell":47580},"1910":{"tab":203,"tier":10,"col":1,"max":1,"spell":47585},"1911":{"tab":202,"tier":10,"col":1,"max":1,"spell":47788},"1912":{"tab":283,"tier":6,"col":2,"max":3,"spell":48384},"1913":{"tab":283,"tier":7,"col":0,"max":3,"spell":48389},"1914":{"tab":281,"tier":3,"col":3,"max":2,"spell":48409},"1915":{"tab":282,"tier":2,"col":2,"max":2,"spell":48411},"1916":{"tab":282,"tier":9,"col":2,"max":5,"spell":51179},"1917":{"tab":282,"tier":10,"col":1,"max":1,"spell":48438},"1918":{"tab":281,"tier":9,"col":1,"max":5,"spell":48432},"1919":{"tab":281,"tier":7,"col":3,"max":3,"spell":48483},"1920":{"tab":281,"tier":8,"col":2,"max":3,"spell":48532},"1921":{"tab":281,"tier":8,"col":0,"max":3,"spell":48492},"1922":{"tab":282,"tier":7,"col":2,"max":3,"spell":48496},"1923":{"tab":283,"tier":8,"col":1,"max":1,"spell":50516},"1924":{"tab":283,"tier":8,"col":0,"max":3,"spell":48516},"1925":{"tab":283,"tier":8,"col":3,"max":2,"spell":48488},"1926":{"tab":283,"tier":10,"col":1,"max":1,"spell":48505},"1927":{"tab":281,"tier":10,"col":1,"max":1,"spell":50334},"1928":{"tab":283,"tier":9,"col":1,"max":3,"spell":48506},"1929":{"tab":282,"tier":8,"col":0,"max":3,"spell":48539},"1930":{"tab":282,"tier":8,"col":2,"max":3,"spell":48535},"1932":{"tab":400,"tier":0,"col":1,"max":3,"spell":48962},"1933":{"tab":400,"tier":1,"col":1,"max":3,"spell":48963},"1934":{"tab":400,"tier":1,"col":3,"max":3,"spell":48965},"1936":{"tab":398,"tier":6,"col":2,"max":2,"spell":50365},"1938":{"tab":398,"tier":1,"col":0,"max":5,"spell":48978},"1939":{"tab":398,"tier":0,"col":0,"max":2,"spell":48979},"1941":{"tab":398,"tier":2,"col":0,"max":1,"spell":48982},"1942":{"tab":398,"tier":3,"col":0,"max":3,"spell":48985},"1943":{"tab":398,"tier":2,"col":1,"max":5,"spell":48987},"1944":{"tab":398,"tier":5,"col":1,"max":3,"spell":48988},"1945":{"tab":398,"tier":0,"col":1,"max":3,"spell":48997},"1948":{"tab":398,"tier":1,"col":1,"max":3,"spell":49004},"1949":{"tab":398,"tier":4,"col":3,"max":1,"spell":49005},"1950":{"tab":398,"tier":4,"col":2,"max":3,"spell":49006},"1953":{"tab":398,"tier":3,"col":3,"max":3,"spell":49015},"1954":{"tab":398,"tier":6,"col":1,"max":1,"spell":49016},"1955":{"tab":398,"tier":7,"col":1,"max":3,"spell":49018},"1957":{"tab":398,"tier":8,"col":1,"max":1,"spell":55050},"1958":{"tab":398,"tier":8,"col":2,"max":3,"spell":49023},"1959":{"tab":398,"tier":8,"col":0,"max":3,"spell":49189},"1960":{"tab":398,"tier":6,"col":0,"max":3,"spell":49027},"1961":{"tab":398,"tier":10,"col":1,"max":1,"spell":49028},"1962":{"tab":400,"tier":7,"col":1,"max":3,"spell":49032},"1963":{"tab":400,"tier":1,"col":0,"max":2,"spell":49036},"1968":{"tab":399,"tier":0,"col":2,"max":5,"spell":49042},"1971":{"tab":399,"tier":3,"col":3,"max":2,"spell":49137},"1973":{"tab":399,"tier":1,"col":2,"max":5,"spell":49140},"1975":{"tab":399,"tier":8,"col":1,"max":1,"spell":49143},"1979":{"tab":399,"tier":7,"col":2,"max":1,"spell":51271},"1980":{"tab":399,"tier":4,"col":3,"max":1,"spell":49796},"1981":{"tab":399,"tier":3,"col":2,"max":2,"spell":49149},"1984":{"tab":400,"tier":5,"col":3,"max":1,"spell":52143},"1985":{"tab":400,"tier":2,"col":2,"max":1,"spell":49158},"1989":{"tab":399,"tier":10,"col":1,"max":1,"spell":49184},"1990":{"tab":399,"tier":4,"col":1,"max":3,"spell":49186},"1992":{"tab":399,"tier":5,"col":2,"max":3,"spell":49188},"1993":{"tab":399,"tier":5,"col":1,"max":2,"spell":49024},"1996":{"tab":400,"tier":4,"col":0,"max":1,"spell":49194},"1997":{"tab":399,"tier":8,"col":0,"max":3,"spell":49200},"1998":{"tab":399,"tier":9,"col":1,"max":5,"spell":49202},"1999":{"tab":399,"tier":6,"col":1,"max":1,"spell":49203},"2000":{"tab":400,"tier":10,"col":1,"max":1,"spell":49206},"2001":{"tab":400,"tier":5,"col":2,"max":3,"spell":49208},"2003":{"tab":400,"tier":8,"col":0,"max":3,"spell":49217},"2004":{"tab":400,"tier":3,"col":2,"max":3,"spell":49219},"2005":{"tab":400,"tier":4,"col":1,"max":5,"spell":49220},"2007":{"tab":400,"tier":7,"col":2,"max":1,"spell":49222},"2008":{"tab":400,"tier":2,"col":0,"max":3,"spell":49013},"2009":{"tab":400,"tier":5,"col":1,"max":3,"spell":49224},"2011":{"tab":400,"tier":4,"col":2,"max":2,"spell":49223},"2013":{"tab":400,"tier":6,"col":2,"max":2,"spell":50391},"2015":{"tab":398,"tier":4,"col":0,"max":3,"spell":48977},"2017":{"tab":398,"tier":0,"col":2,"max":5,"spell":49182},"2018":{"tab":398,"tier":3,"col":2,"max":3,"spell":49145},"2019":{"tab":398,"tier":7,"col":2,"max":1,"spell":55233},"2020":{"tab":399,"tier":0,"col":1,"max":2,"spell":49455},"2022":{"tab":399,"tier":1,"col":3,"max":3,"spell":49226},"2025":{"tab":400,"tier":1,"col":2,"max":2,"spell":49588},"2027":{"tab":203,"tier":0,"col":1,"max":2,"spell":15337},"2029":{"tab":399,"tier":6,"col":2,"max":2,"spell":50384},"2030":{"tab":399,"tier":4,"col":2,"max":3,"spell":49471},"2031":{"tab":399,"tier":0,"col":0,"max":3,"spell":49175},"2034":{"tab":398,"tier":9,"col":1,"max":5,"spell":61154},"2035":{"tab":399,"tier":1,"col":1,"max":2,"spell":55061},"2036":{"tab":400,"tier":9,"col":1,"max":5,"spell":50117},"2039":{"tab":400,"tier":3,"col":1,"max":2,"spell":49146},"2040":{"tab":399,"tier":8,"col":2,"max":3,"spell":50187},"2041":{"tab":302,"tier":10,"col":1,"max":1,"spell":48181},"2042":{"tab":399,"tier":2,"col":0,"max":5,"spell":50880},"2043":{"tab":400,"tier":8,"col":1,"max":3,"spell":51099},"2044":{"tab":399,"tier":3,"col":1,"max":5,"spell":51123},"2045":{"tab":301,"tier":8,"col":2,"max":3,"spell":47220},"2047":{"tab":400,"tier":2,"col":1,"max":5,"spell":51459},"2048":{"tab":399,"tier":2,"col":2,"max":3,"spell":51468},"2049":{"tab":261,"tier":7,"col":1,"max":2,"spell":51466},"2050":{"tab":261,"tier":8,"col":0,"max":3,"spell":51474},"2051":{"tab":261,"tier":8,"col":2,"max":3,"spell":51480},"2052":{"tab":261,"tier":6,"col":2,"max":3,"spell":51483},"2053":{"tab":261,"tier":10,"col":1,"max":1,"spell":51490},"2054":{"tab":263,"tier":7,"col":2,"max":2,"spell":51521},"2055":{"tab":263,"tier":7,"col":0,"max":3,"spell":51525},"2056":{"tab":263,"tier":8,"col":2,"max":2,"spell":51523},"2057":{"tab":263,"tier":9,"col":1,"max":5,"spell":51528},"2058":{"tab":263,"tier":10,"col":1,"max":1,"spell":51533},"2059":{"tab":262,"tier":8,"col":2,"max":2,"spell":51560},"2060":{"tab":262,"tier":7,"col":0,"max":2,"spell":51554},"2061":{"tab":262,"tier":8,"col":0,"max":3,"spell":51556},"2063":{"tab":262,"tier":9,"col":1,"max":5,"spell":51562},"2064":{"tab":262,"tier":10,"col":1,"max":1,"spell":61295},"2065":{"tab":182,"tier":6,"col":0,"max":2,"spell":51625},"2066":{"tab":182,"tier":8,"col":2,"max":3,"spell":51627},"2068":{"tab":182,"tier":1,"col":1,"max":2,"spell":51632},"2069":{"tab":182,"tier":7,"col":0,"max":3,"spell":51634},"2070":{"tab":182,"tier":9,"col":1,"max":5,"spell":51664},"2071":{"tab":182,"tier":10,"col":1,"max":1,"spell":51662},"2072":{"tab":181,"tier":7,"col":0,"max":2,"spell":5952},"2073":{"tab":181,"tier":8,"col":0,"max":2,"spell":51672},"2074":{"tab":181,"tier":8,"col":2,"max":2,"spell":51682},"2075":{"tab":181,"tier":9,"col":1,"max":5,"spell":51685},"2076":{"tab":181,"tier":10,"col":1,"max":1,"spell":51690},"2077":{"tab":183,"tier":7,"col":2,"max":2,"spell":51692},"2078":{"tab":183,"tier":8,"col":0,"max":3,"spell":51698},"2079":{"tab":183,"tier":8,"col":2,"max":2,"spell":58414},"2080":{"tab":183,"tier":9,"col":1,"max":5,"spell":51708},"2081":{"tab":183,"tier":10,"col":1,"max":1,"spell":51713},"2082":{"tab":400,"tier":0,"col":0,"max":2,"spell":51745},"2083":{"tab":263,"tier":4,"col":2,"max":3,"spell":51883},"2084":{"tab":262,"tier":6,"col":2,"max":1,"spell":51886},"2085":{"tab":400,"tier":6,"col":3,"max":1,"spell":63560},"2086":{"tab":398,"tier":2,"col":2,"max":3,"spell":49467},"2101":{"tab":263,"tier":0,"col":1,"max":2,"spell":16043},"2105":{"tab":398,"tier":5,"col":2,"max":2,"spell":53137},"2106":{"tab":410,"tier":2,"col":0,"max":3,"spell":61680},"2107":{"tab":410,"tier":0,"col":0,"max":2,"spell":61682},"2109":{"tab":410,"tier":0,"col":1,"max":1,"spell":61684},"2110":{"tab":409,"tier":2,"col":0,"max":3,"spell":61680},"2111":{"tab":410,"tier":2,"col":3,"max":1,"spell":61685},"2112":{"tab":410,"tier":0,"col":2,"max":3,"spell":61686},"2113":{"tab":410,"tier":0,"col":3,"max":2,"spell":61689},"2114":{"tab":409,"tier":0,"col":0,"max":2,"spell":61682},"2116":{"tab":409,"tier":0,"col":2,"max":3,"spell":61686},"2117":{"tab":409,"tier":0,"col":3,"max":2,"spell":61689},"2118":{"tab":411,"tier":0,"col":0,"max":2,"spell":61682},"2119":{"tab":411,"tier":0,"col":1,"max":1,"spell":61684},"2120":{"tab":411,"tier":0,"col":2,"max":3,"spell":61686},"2121":{"tab":411,"tier":0,"col":3,"max":2,"spell":61689},"2122":{"tab":409,"tier":1,"col":3,"max":2,"spell":53175},"2123":{"tab":409,"tier":2,"col":1,"max":2,"spell":53178},"2124":{"tab":410,"tier":1,"col":0,"max":2,"spell":53180},"2125":{"tab":410,"tier":1,"col":2,"max":3,"spell":53182},"2126":{"tab":409,"tier":1,"col":0,"max":3,"spell":53182},"2127":{"tab":411,"tier":1,"col":3,"max":3,"spell":53182},"2128":{"tab":410,"tier":1,"col":1,"max":2,"spell":53186},"2129":{"tab":410,"tier":3,"col":2,"max":3,"spell":53203},"2130":{"tab":363,"tier":6,"col":0,"max":3,"spell":53234},"2131":{"tab":363,"tier":7,"col":2,"max":2,"spell":53228},"2132":{"tab":363,"tier":8,"col":0,"max":3,"spell":53215},"2133":{"tab":363,"tier":8,"col":2,"max":3,"spell":53221},"2134":{"tab":363,"tier":9,"col":1,"max":5,"spell":53241},"2135":{"tab":363,"tier":10,"col":1,"max":1,"spell":53209},"2136":{"tab":361,"tier":7,"col":0,"max":2,"spell":53252},"2137":{"tab":361,"tier":8,"col":2,"max":3,"spell":53256},"2138":{"tab":361,"tier":2,"col":1,"max":1,"spell":53265},"2139":{"tab":361,"tier":10,"col":1,"max":1,"spell":53270},"2140":{"tab":361,"tier":8,"col":0,"max":3,"spell":53262},"2141":{"tab":362,"tier":7,"col":1,"max":3,"spell":53295},"2142":{"tab":362,"tier":8,"col":0,"max":2,"spell":53298},"2143":{"tab":362,"tier":8,"col":3,"max":3,"spell":53302},"2144":{"tab":362,"tier":9,"col":2,"max":3,"spell":53290},"2145":{"tab":362,"tier":10,"col":1,"max":1,"spell":53301},"2147":{"tab":381,"tier":7,"col":2,"max":2,"spell":53375},"2148":{"tab":381,"tier":8,"col":0,"max":3,"spell":53379},"2149":{"tab":381,"tier":9,"col":1,"max":3,"spell":53380},"2150":{"tab":381,"tier":10,"col":1,"max":1,"spell":53385},"2151":{"tab":410,"tier":1,"col":3,"max":1,"spell":19596},"2152":{"tab":410,"tier":2,"col":2,"max":2,"spell":53409},"2153":{"tab":410,"tier":4,"col":1,"max":1,"spell":53426},"2154":{"tab":410,"tier":3,"col":3,"max":3,"spell":53427},"2155":{"tab":410,"tier":4,"col":0,"max":1,"spell":53401},"2156":{"tab":410,"tier":3,"col":1,"max":1,"spell":55709},"2157":{"tab":410,"tier":4,"col":2,"max":1,"spell":53434},"2160":{"tab":409,"tier":1,"col":1,"max":1,"spell":19596},"2161":{"tab":409,"tier":3,"col":3,"max":3,"spell":53427},"2162":{"tab":409,"tier":2,"col":2,"max":2,"spell":53409},"2163":{"tab":409,"tier":3,"col":2,"max":2,"spell":53450},"2165":{"tab":411,"tier":1,"col":0,"max":1,"spell":19596},"2166":{"tab":411,"tier":2,"col":0,"max":3,"spell":61680},"2167":{"tab":411,"tier":2,"col":1,"max":2,"spell":53409},"2168":{"tab":411,"tier":3,"col":1,"max":3,"spell":53427},"2169":{"tab":409,"tier":4,"col":3,"max":1,"spell":53476},"2170":{"tab":409,"tier":4,"col":1,"max":1,"spell":53477},"2171":{"tab":409,"tier":4,"col":0,"max":1,"spell":53478},"2172":{"tab":409,"tier":4,"col":2,"max":1,"spell":53480},"2173":{"tab":409,"tier":1,"col":2,"max":2,"spell":53481},"2175":{"tab":411,"tier":4,"col":2,"max":1,"spell":53490},"2176":{"tab":381,"tier":6,"col":0,"max":2,"spell":53486},"2177":{"tab":411,"tier":3,"col":2,"max":2,"spell":52234},"2179":{"tab":381,"tier":8,"col":2,"max":3,"spell":53501},"2181":{"tab":411,"tier":4,"col":0,"max":1,"spell":53508},"2182":{"tab":411,"tier":1,"col":2,"max":2,"spell":53514},"2183":{"tab":411,"tier":3,"col":3,"max":2,"spell":53511},"2184":{"tab":411,"tier":4,"col":1,"max":1,"spell":53517},"2185":{"tab":383,"tier":0,"col":2,"max":5,"spell":20262},"2190":{"tab":382,"tier":7,"col":0,"max":3,"spell":53551},"2191":{"tab":382,"tier":9,"col":2,"max":2,"spell":53556},"2192":{"tab":382,"tier":10,"col":1,"max":1,"spell":53563},"2193":{"tab":382,"tier":9,"col":1,"max":2,"spell":53569},"2194":{"tab":383,"tier":8,"col":2,"max":2,"spell":53583},"2195":{"tab":383,"tier":8,"col":0,"max":3,"spell":53590},"2196":{"tab":383,"tier":10,"col":1,"max":1,"spell":53595},"2197":{"tab":363,"tier":0,"col":1,"max":3,"spell":53620},"2198":{"tab":382,"tier":3,"col":3,"max":2,"spell":53660},"2199":{"tab":382,"tier":8,"col":2,"max":5,"spell":53671},"2200":{"tab":383,"tier":9,"col":2,"max":2,"spell":53695},"2201":{"tab":411,"tier":0,"col":1,"max":1,"spell":23145},"2203":{"tab":410,"tier":0,"col":1,"max":1,"spell":23145},"2204":{"tab":383,"tier":9,"col":1,"max":3,"spell":53709},"2205":{"tab":302,"tier":2,"col":0,"max":2,"spell":53754},"2206":{"tab":411,"tier":2,"col":2,"max":1,"spell":54044},"2207":{"tab":411,"tier":1,"col":1,"max":2,"spell":53483},"2208":{"tab":411,"tier":1,"col":1,"max":2,"spell":53554},"2209":{"tab":81,"tier":8,"col":2,"max":5,"spell":44404},"2210":{"tab":399,"tier":7,"col":1,"max":3,"spell":54639},"2211":{"tab":81,"tier":2,"col":3,"max":1,"spell":54646},"2212":{"tab":41,"tier":1,"col":1,"max":2,"spell":54747},"2214":{"tab":61,"tier":6,"col":0,"max":2,"spell":44745},"2215":{"tab":399,"tier":2,"col":1,"max":1,"spell":49039},"2216":{"tab":400,"tier":8,"col":2,"max":1,"spell":55090},"2217":{"tab":398,"tier":1,"col":2,"max":2,"spell":55107},"2218":{"tab":400,"tier":0,"col":2,"max":5,"spell":55129},"2219":{"tab":410,"tier":2,"col":3,"max":1,"spell":52825},"2221":{"tab":400,"tier":6,"col":1,"max":1,"spell":51052},"2222":{"tab":81,"tier":3,"col":3,"max":3,"spell":29447},"2223":{"tab":399,"tier":5,"col":0,"max":1,"spell":55610},"2225":{"tab":400,"tier":3,"col":3,"max":2,"spell":55620},"2226":{"tab":400,"tier":5,"col":0,"max":2,"spell":55666},"2227":{"tab":361,"tier":9,"col":1,"max":5,"spell":56314},"2228":{"tab":362,"tier":4,"col":0,"max":3,"spell":56339},"2229":{"tab":362,"tier":3,"col":1,"max":3,"spell":56333},"2231":{"tab":161,"tier":9,"col":1,"max":5,"spell":46867},"2232":{"tab":161,"tier":3,"col":2,"max":3,"spell":56636},"2233":{"tab":161,"tier":6,"col":3,"max":2,"spell":12862},"2234":{"tab":164,"tier":9,"col":1,"max":5,"spell":56927},"2235":{"tab":201,"tier":7,"col":0,"max":2,"spell":57470},"2236":{"tab":163,"tier":8,"col":0,"max":1,"spell":57499},"2237":{"tab":409,"tier":0,"col":1,"max":1,"spell":61685},"2238":{"tab":283,"tier":0,"col":2,"max":5,"spell":57810},"2239":{"tab":283,"tier":4,"col":2,"max":3,"spell":57849},"2240":{"tab":283,"tier":2,"col":2,"max":1,"spell":57865},"2241":{"tab":281,"tier":7,"col":0,"max":3,"spell":57873},"2242":{"tab":281,"tier":5,"col":0,"max":3,"spell":57878},"2244":{"tab":183,"tier":0,"col":0,"max":5,"spell":14179},"2245":{"tab":302,"tier":8,"col":2,"max":1,"spell":58435},"2246":{"tab":163,"tier":9,"col":2,"max":2,"spell":58872},"2247":{"tab":163,"tier":3,"col":0,"max":2,"spell":59088},"2249":{"tab":263,"tier":7,"col":1,"max":1,"spell":60103},"2250":{"tab":164,"tier":0,"col":0,"max":3,"spell":61216},"2252":{"tab":261,"tier":9,"col":1,"max":5,"spell":62097},"2253":{"tab":410,"tier":5,"col":2,"max":2,"spell":62758},"2254":{"tab":410,"tier":5,"col":0,"max":2,"spell":62759},"2255":{"tab":409,"tier":5,"col":2,"max":2,"spell":62758},"2256":{"tab":411,"tier":5,"col":0,"max":2,"spell":62758},"2257":{"tab":411,"tier":4,"col":3,"max":2,"spell":53450},"2258":{"tab":409,"tier":5,"col":1,"max":2,"spell":62764},"2259":{"tab":398,"tier":7,"col":0,"max":2,"spell":62905},"2260":{"tab":399,"tier":6,"col":0,"max":3,"spell":50040},"2261":{"tab":303,"tier":7,"col":2,"max":2,"spell":63156},"2262":{"tab":261,"tier":7,"col":0,"max":2,"spell":63370},"2263":{"tab":263,"tier":5,"col":3,"max":2,"spell":63373},"2264":{"tab":282,"tier":9,"col":0,"max":2,"spell":63410},"2266":{"tab":281,"tier":9,"col":2,"max":1,"spell":63503},"2267":{"tab":203,"tier":5,"col":2,"max":3,"spell":63625},"2268":{"tab":201,"tier":4,"col":0,"max":2,"spell":33201},"2277":{"tab":409,"tier":2,"col":3,"max":1,"spell":63900},"2278":{"tab":411,"tier":5,"col":3,"max":1,"spell":53480},"2279":{"tab":202,"tier":7,"col":0,"max":2,"spell":64127},"2280":{"tab":383,"tier":2,"col":0,"max":1,"spell":64205},"2281":{"tab":383,"tier":3,"col":0,"max":2,"spell":53527},"2282":{"tab":383,"tier":6,"col":0,"max":2,"spell":31785},"2283":{"tab":161,"tier":7,"col":0,"max":1,"spell":64976},"2284":{"tab":399,"tier":7,"col":0,"max":3,"spell":65661},"2285":{"tab":400,"tier":6,"col":0,"max":5,"spell":66799}} \ No newline at end of file diff --git a/web-next/lib/data/talent-tabs.json b/web-next/lib/data/talent-tabs.json new file mode 100644 index 0000000..0360ff7 --- /dev/null +++ b/web-next/lib/data/talent-tabs.json @@ -0,0 +1 @@ +{"23":41,"24":41,"25":41,"26":41,"27":41,"28":41,"29":41,"30":41,"31":41,"32":41,"33":41,"34":41,"35":41,"36":41,"37":61,"38":61,"61":61,"62":61,"63":61,"64":61,"65":61,"66":61,"67":61,"68":61,"69":61,"70":61,"71":61,"72":61,"73":61,"74":81,"75":81,"76":81,"77":81,"80":81,"81":81,"82":81,"83":81,"85":81,"86":81,"87":81,"88":81,"121":161,"123":161,"124":161,"125":161,"126":161,"127":161,"128":161,"129":161,"130":161,"131":161,"132":161,"133":161,"134":161,"135":161,"136":161,"137":161,"138":163,"140":163,"141":163,"142":163,"144":163,"146":163,"147":163,"148":163,"149":163,"150":163,"151":163,"152":163,"153":163,"154":164,"155":164,"156":164,"157":164,"158":164,"159":164,"160":164,"161":164,"165":164,"166":164,"167":164,"181":181,"182":181,"184":181,"186":181,"187":181,"201":181,"203":181,"204":181,"205":181,"206":181,"221":181,"222":181,"223":181,"241":183,"242":181,"244":183,"245":183,"246":183,"247":183,"261":183,"262":183,"263":183,"265":183,"268":182,"269":182,"270":182,"272":182,"273":182,"274":182,"276":182,"277":182,"278":182,"279":182,"280":182,"281":182,"283":182,"284":183,"301":181,"303":183,"321":201,"322":201,"341":201,"342":201,"343":201,"344":201,"346":201,"347":201,"348":201,"350":201,"351":201,"352":201,"361":202,"381":183,"382":182,"401":202,"402":202,"403":202,"404":202,"406":202,"408":202,"410":202,"411":202,"413":202,"421":81,"442":202,"461":203,"462":203,"463":203,"465":203,"466":203,"481":203,"482":203,"483":203,"484":203,"501":203,"521":203,"541":203,"542":203,"561":261,"562":261,"563":261,"564":261,"565":261,"567":261,"573":261,"574":261,"575":261,"581":262,"582":262,"583":262,"586":262,"587":262,"588":262,"589":262,"590":262,"591":262,"592":262,"593":262,"594":262,"595":262,"601":263,"602":263,"605":263,"607":263,"609":263,"610":263,"611":263,"613":263,"614":263,"615":263,"616":263,"617":263,"641":161,"661":164,"662":161,"681":183,"682":182,"702":163,"721":261,"741":61,"762":283,"763":283,"764":283,"782":283,"783":283,"784":283,"788":283,"789":283,"790":283,"792":283,"793":283,"794":281,"795":281,"796":281,"797":281,"798":281,"799":281,"801":281,"802":281,"803":281,"804":281,"805":281,"807":281,"808":281,"809":281,"821":282,"822":282,"823":282,"824":282,"825":282,"826":282,"827":282,"828":282,"829":282,"830":282,"831":282,"841":282,"842":282,"843":282,"844":282,"881":203,"901":263,"941":301,"943":301,"944":301,"961":301,"963":301,"964":301,"965":301,"966":301,"967":301,"968":301,"981":301,"982":301,"983":301,"985":301,"986":301,"1001":302,"1002":302,"1003":302,"1004":302,"1005":302,"1006":302,"1007":302,"1021":302,"1022":302,"1041":302,"1042":302,"1061":302,"1081":302,"1101":302,"1122":181,"1123":183,"1141":41,"1142":81,"1162":281,"1181":202,"1201":201,"1202":201,"1221":303,"1222":303,"1223":303,"1224":303,"1225":303,"1226":303,"1227":303,"1242":303,"1243":303,"1244":303,"1261":303,"1262":303,"1263":303,"1281":303,"1282":303,"1283":303,"1284":302,"1303":362,"1304":362,"1305":362,"1306":362,"1309":362,"1310":362,"1311":362,"1312":362,"1321":362,"1322":362,"1325":362,"1341":363,"1342":363,"1343":363,"1344":363,"1345":363,"1346":363,"1347":363,"1348":363,"1349":363,"1351":363,"1353":363,"1361":363,"1362":363,"1381":361,"1382":361,"1384":361,"1385":361,"1386":361,"1387":361,"1388":361,"1389":361,"1390":361,"1393":361,"1395":361,"1396":361,"1397":361,"1401":381,"1402":381,"1403":381,"1407":381,"1410":381,"1411":381,"1421":383,"1422":383,"1423":383,"1425":383,"1426":383,"1429":383,"1430":383,"1431":383,"1432":382,"1433":382,"1435":382,"1441":381,"1442":383,"1443":382,"1444":382,"1446":382,"1449":382,"1450":382,"1461":382,"1463":382,"1464":381,"1465":382,"1481":381,"1501":383,"1502":382,"1521":383,"1541":164,"1542":164,"1543":164,"1561":202,"1581":164,"1601":163,"1621":362,"1622":362,"1623":362,"1624":361,"1625":361,"1627":382,"1628":382,"1629":383,"1631":381,"1632":381,"1633":381,"1634":381,"1635":202,"1636":202,"1637":202,"1638":203,"1639":41,"1640":261,"1641":261,"1642":261,"1643":263,"1645":261,"1646":262,"1647":263,"1648":262,"1649":61,"1650":81,"1652":163,"1653":163,"1654":163,"1655":164,"1657":164,"1658":164,"1659":164,"1660":163,"1661":161,"1662":161,"1663":161,"1664":161,"1666":163,"1667":302,"1668":302,"1669":302,"1670":302,"1671":303,"1672":303,"1673":303,"1676":301,"1677":301,"1678":301,"1679":301,"1680":303,"1682":261,"1685":261,"1686":261,"1687":261,"1689":263,"1690":263,"1691":263,"1692":263,"1693":263,"1695":262,"1696":262,"1697":262,"1698":262,"1699":262,"1700":183,"1701":183,"1702":183,"1703":181,"1705":181,"1706":181,"1707":181,"1709":181,"1711":183,"1712":183,"1713":183,"1714":183,"1715":182,"1718":182,"1719":182,"1721":182,"1722":183,"1723":182,"1724":81,"1725":81,"1726":81,"1727":81,"1728":81,"1729":81,"1730":41,"1731":41,"1732":41,"1733":41,"1734":41,"1735":41,"1736":61,"1737":61,"1738":61,"1740":61,"1741":61,"1742":382,"1743":382,"1744":382,"1745":382,"1746":382,"1747":382,"1748":383,"1750":383,"1751":383,"1753":383,"1754":383,"1755":381,"1756":381,"1757":381,"1758":381,"1759":381,"1761":381,"1762":182,"1763":302,"1764":302,"1765":202,"1766":202,"1767":202,"1768":202,"1769":201,"1771":201,"1772":201,"1773":201,"1774":201,"1777":203,"1778":203,"1779":203,"1781":203,"1782":283,"1783":283,"1784":283,"1785":283,"1786":283,"1787":283,"1788":282,"1789":282,"1790":282,"1791":282,"1792":281,"1793":281,"1794":281,"1795":281,"1796":281,"1797":282,"1798":281,"1799":361,"1800":361,"1801":361,"1802":361,"1803":361,"1804":363,"1806":363,"1807":363,"1808":363,"1809":362,"1810":362,"1811":362,"1812":362,"1813":362,"1814":362,"1815":202,"1816":203,"1817":301,"1818":363,"1819":363,"1820":362,"1821":363,"1822":283,"1823":381,"1824":161,"1825":181,"1826":81,"1827":181,"1843":81,"1844":81,"1845":81,"1846":81,"1847":81,"1848":41,"1849":41,"1850":41,"1851":41,"1852":41,"1853":61,"1854":61,"1855":61,"1856":61,"1857":61,"1858":201,"1859":161,"1860":161,"1862":161,"1863":161,"1864":164,"1865":164,"1866":164,"1867":164,"1868":164,"1870":163,"1871":163,"1872":163,"1873":302,"1875":302,"1876":302,"1878":302,"1880":303,"1882":303,"1883":303,"1884":303,"1885":303,"1886":303,"1887":301,"1888":301,"1889":301,"1890":301,"1891":301,"1893":163,"1894":201,"1895":201,"1896":201,"1897":201,"1898":201,"1901":201,"1902":202,"1903":202,"1904":202,"1905":202,"1906":203,"1907":203,"1908":203,"1909":203,"1910":203,"1911":202,"1912":283,"1913":283,"1914":281,"1915":282,"1916":282,"1917":282,"1918":281,"1919":281,"1920":281,"1921":281,"1922":282,"1923":283,"1924":283,"1925":283,"1926":283,"1927":281,"1928":283,"1929":282,"1930":282,"1932":400,"1933":400,"1934":400,"1936":398,"1938":398,"1939":398,"1941":398,"1942":398,"1943":398,"1944":398,"1945":398,"1948":398,"1949":398,"1950":398,"1953":398,"1954":398,"1955":398,"1957":398,"1958":398,"1959":398,"1960":398,"1961":398,"1962":400,"1963":400,"1968":399,"1971":399,"1973":399,"1975":399,"1979":399,"1980":399,"1981":399,"1984":400,"1985":400,"1989":399,"1990":399,"1992":399,"1993":399,"1996":400,"1997":399,"1998":399,"1999":399,"2000":400,"2001":400,"2003":400,"2004":400,"2005":400,"2007":400,"2008":400,"2009":400,"2011":400,"2013":400,"2015":398,"2017":398,"2018":398,"2019":398,"2020":399,"2022":399,"2025":400,"2027":203,"2029":399,"2030":399,"2031":399,"2034":398,"2035":399,"2036":400,"2039":400,"2040":399,"2041":302,"2042":399,"2043":400,"2044":399,"2045":301,"2047":400,"2048":399,"2049":261,"2050":261,"2051":261,"2052":261,"2053":261,"2054":263,"2055":263,"2056":263,"2057":263,"2058":263,"2059":262,"2060":262,"2061":262,"2063":262,"2064":262,"2065":182,"2066":182,"2068":182,"2069":182,"2070":182,"2071":182,"2072":181,"2073":181,"2074":181,"2075":181,"2076":181,"2077":183,"2078":183,"2079":183,"2080":183,"2081":183,"2082":400,"2083":263,"2084":262,"2085":400,"2086":398,"2101":263,"2105":398,"2106":410,"2107":410,"2109":410,"2110":409,"2111":410,"2112":410,"2113":410,"2114":409,"2116":409,"2117":409,"2118":411,"2119":411,"2120":411,"2121":411,"2122":409,"2123":409,"2124":410,"2125":410,"2126":409,"2127":411,"2128":410,"2129":410,"2130":363,"2131":363,"2132":363,"2133":363,"2134":363,"2135":363,"2136":361,"2137":361,"2138":361,"2139":361,"2140":361,"2141":362,"2142":362,"2143":362,"2144":362,"2145":362,"2147":381,"2148":381,"2149":381,"2150":381,"2151":410,"2152":410,"2153":410,"2154":410,"2155":410,"2156":410,"2157":410,"2160":409,"2161":409,"2162":409,"2163":409,"2165":411,"2166":411,"2167":411,"2168":411,"2169":409,"2170":409,"2171":409,"2172":409,"2173":409,"2175":411,"2176":381,"2177":411,"2179":381,"2181":411,"2182":411,"2183":411,"2184":411,"2185":383,"2190":382,"2191":382,"2192":382,"2193":382,"2194":383,"2195":383,"2196":383,"2197":363,"2198":382,"2199":382,"2200":383,"2201":411,"2203":410,"2204":383,"2205":302,"2206":411,"2207":411,"2208":411,"2209":81,"2210":399,"2211":81,"2212":41,"2214":61,"2215":399,"2216":400,"2217":398,"2218":400,"2219":410,"2221":400,"2222":81,"2223":399,"2225":400,"2226":400,"2227":361,"2228":362,"2229":362,"2231":161,"2232":161,"2233":161,"2234":164,"2235":201,"2236":163,"2237":409,"2238":283,"2239":283,"2240":283,"2241":281,"2242":281,"2244":183,"2245":302,"2246":163,"2247":163,"2249":263,"2250":164,"2252":261,"2253":410,"2254":410,"2255":409,"2256":411,"2257":411,"2258":409,"2259":398,"2260":399,"2261":303,"2262":261,"2263":263,"2264":282,"2266":281,"2267":203,"2268":201,"2277":409,"2278":411,"2279":202,"2280":383,"2281":383,"2282":383,"2283":161,"2284":399,"2285":400} \ No newline at end of file diff --git a/web-next/lib/wow-data.ts b/web-next/lib/wow-data.ts index fa64886..7cdd4cd 100644 --- a/web-next/lib/wow-data.ts +++ b/web-next/lib/wow-data.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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', +} diff --git a/web-next/lib/wowhead.ts b/web-next/lib/wowhead.ts index 36fdd36..53e9a60 100644 --- a/web-next/lib/wowhead.ts +++ b/web-next/lib/wowhead.ts @@ -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 } diff --git a/web-next/messages/en.json b/web-next/messages/en.json index a975dd7..c26ef8c 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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" } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 1aad777..de738d2 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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" } } diff --git a/web-next/middleware.ts b/web-next/middleware.ts index 3a81c69..f045a2b 100644 --- a/web-next/middleware.ts +++ b/web-next/middleware.ts @@ -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|.*\\..*).*)'], } diff --git a/web-next/next.config.ts b/web-next/next.config.ts index d8b7f6e..5236b7d 100644 --- a/web-next/next.config.ts +++ b/web-next/next.config.ts @@ -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)