Foro: autor con personaje aleatorio de la cuenta
- El nombre del autor muestra un personaje ALEATORIO de la cuenta (no la id). - Nombre con <Hermandad> en dorado (oculto si el personaje no tiene guild). - Avatar = imagen de raza del personaje (raza+género). - Cajas nuevas: puntos de logros, nivel y clase (con su color de clase). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
import { formatForumDate } from '@/lib/forum-format'
|
import { formatForumDate } from '@/lib/forum-format'
|
||||||
import { localizeWowheadLinks } from '@/lib/forum-wowhead'
|
import { localizeWowheadLinks } from '@/lib/forum-wowhead'
|
||||||
import { getSession } from '@/lib/session'
|
import { getSession } from '@/lib/session'
|
||||||
|
import { className, classColor } from '@/lib/wow-data'
|
||||||
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
|
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
|
||||||
import { PageShell } from '@/components/PageShell'
|
import { PageShell } from '@/components/PageShell'
|
||||||
import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
|
import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
|
||||||
@@ -105,7 +106,10 @@ export default async function TopicPage({
|
|||||||
<div key={post.id} className={`topic_post${isStaff ? ' isStaff' : ''}${post.deleted ? ' post_deleted' : ''}`}>
|
<div key={post.id} className={`topic_post${isStaff ? ' isStaff' : ''}${post.deleted ? ' post_deleted' : ''}`}>
|
||||||
<div className="left_side">
|
<div className="left_side">
|
||||||
<div className="username_container">
|
<div className="username_container">
|
||||||
<span className="username">{author?.name ?? `#${post.poster}`}</span>
|
<span className="username">
|
||||||
|
{author?.charName ?? author?.name ?? `#${post.poster}`}
|
||||||
|
{author?.guildName ? <span className="username_guild"> <{author.guildName}></span> : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`user_avatar${isStaff ? ' isStaff' : ''}`}>
|
<div className={`user_avatar${isStaff ? ' isStaff' : ''}`}>
|
||||||
<span
|
<span
|
||||||
@@ -128,6 +132,30 @@ export default async function TopicPage({
|
|||||||
</dt>
|
</dt>
|
||||||
<dd>{formatForumDate(author?.joindate ?? null, locale) || '—'}</dd>
|
<dd>{formatForumDate(author?.joindate ?? null, locale) || '—'}</dd>
|
||||||
</h3>
|
</h3>
|
||||||
|
{author?.charName ? (
|
||||||
|
<>
|
||||||
|
<h3 className="post_field">
|
||||||
|
<dt>
|
||||||
|
<i className="fa-solid fa-medal fa-fw" />
|
||||||
|
</dt>
|
||||||
|
<dd>{(author.achPoints ?? 0).toLocaleString(locale)}</dd>
|
||||||
|
</h3>
|
||||||
|
<h3 className="post_field">
|
||||||
|
<dt>
|
||||||
|
<i className="fa-solid fa-star fa-fw" />
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{t('level')} {author.level}
|
||||||
|
</dd>
|
||||||
|
</h3>
|
||||||
|
<h3 className="post_field">
|
||||||
|
<dt>
|
||||||
|
<i className="fa-solid fa-khanda fa-fw" />
|
||||||
|
</dt>
|
||||||
|
<dd style={{ color: classColor(author.classId) }}>{className(author.classId, locale)}</dd>
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<h3 className="post_field">
|
<h3 className="post_field">
|
||||||
<dt>
|
<dt>
|
||||||
<i className="fa-solid fa-comments fa-fw" />
|
<i className="fa-solid fa-comments fa-fw" />
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ export default async function LocaleLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<head>
|
<head>
|
||||||
{/* Verificación de la cuenta de Google AdSense: solo si hay ADSENSE_CLIENT_ID
|
{/* Google AdSense (meta de verificación + adsbygoogle.js): solo si hay
|
||||||
y el visitante no es admin. Ver components/AdSense.tsx. */}
|
ADSENSE_CLIENT_ID, el visitante no es admin ni ha pagado, y —para el
|
||||||
|
script— ha aceptado «Marketing». Ver components/AdSense.tsx. */}
|
||||||
<AdSense />
|
<AdSense />
|
||||||
{/* Iconos del sitio (idénticos a la web Django) */}
|
{/* Iconos del sitio (idénticos a la web Django) */}
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@@ -295,3 +295,6 @@ a.nice_button:focus {
|
|||||||
.topic_post .left_side { flex-basis: 100%; }
|
.topic_post .left_side { flex-basis: 100%; }
|
||||||
.topic_post .left_side .user_avatar { width: 56px; height: 56px; }
|
.topic_post .left_side .user_avatar { width: 56px; height: 56px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nombre de hermandad junto al personaje en el foro */
|
||||||
|
.username_guild { color: #c8a24c; font-weight: 400; font-size: .8em; }
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { shouldServeAds } from '@/lib/ads'
|
import { shouldServeAds } from '@/lib/ads'
|
||||||
|
import { AdSenseScript } from './AdSenseScript'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Etiqueta de verificación de Google AdSense, con DOS interruptores:
|
* Google AdSense: etiqueta de verificación de la cuenta + script de anuncios
|
||||||
|
* («Auto ads»: se pega en todas las páginas y Google decide dónde colocarlos).
|
||||||
|
*
|
||||||
|
* TRES interruptores:
|
||||||
*
|
*
|
||||||
* 1. `ADSENSE_CLIENT_ID` en el .env. Vacío o sin poner = no se emite nada. Es el
|
* 1. `ADSENSE_CLIENT_ID` en el .env. Vacío o sin poner = no se emite nada. Es el
|
||||||
* interruptor del administrador. No lleva prefijo NEXT_PUBLIC_ a propósito:
|
* interruptor del administrador. No lleva prefijo NEXT_PUBLIC_ a propósito:
|
||||||
@@ -10,10 +14,11 @@ import { shouldServeAds } from '@/lib/ads'
|
|||||||
* rebuildear para activarlo o desactivarlo.
|
* rebuildear para activarlo o desactivarlo.
|
||||||
* 2. El filtro común de publicidad (`shouldServeAds`, ver lib/ads.ts): fuera para
|
* 2. El filtro común de publicidad (`shouldServeAds`, ver lib/ads.ts): fuera para
|
||||||
* admins y para quien sea rango 2+ o haya pagado alguna vez.
|
* admins y para quien sea rango 2+ o haya pagado alguna vez.
|
||||||
*
|
* 3. El consentimiento «Marketing» del visitante, pero SOLO para el script, en
|
||||||
* Ojo: esto es solo la etiqueta de verificación de la cuenta, no carga adsbygoogle.js
|
* <AdSenseScript/> (cliente). La <meta> se queda fuera del banner a propósito:
|
||||||
* ni escribe cookies, por eso no pasa por el banner de consentimiento (el script de
|
* es un identificador estático de la cuenta, no carga nada ni escribe cookies,
|
||||||
* anuncios sí lo hace, ver components/Monetag.tsx).
|
* y Google la quiere presente para poder verificar el sitio aunque el visitante
|
||||||
|
* rechace los anuncios.
|
||||||
*/
|
*/
|
||||||
export async function AdSense() {
|
export async function AdSense() {
|
||||||
const client = process.env.ADSENSE_CLIENT_ID
|
const client = process.env.ADSENSE_CLIENT_ID
|
||||||
@@ -21,5 +26,10 @@ export async function AdSense() {
|
|||||||
|
|
||||||
if (!(await shouldServeAds())) return null
|
if (!(await shouldServeAds())) return null
|
||||||
|
|
||||||
return <meta name="google-adsense-account" content={client} />
|
return (
|
||||||
|
<>
|
||||||
|
<meta name="google-adsense-account" content={client} />
|
||||||
|
<AdSenseScript client={client} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Script from 'next/script'
|
||||||
|
import { useConsent } from './CookieConsent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parte de cliente de AdSense: el script que de verdad pinta los anuncios
|
||||||
|
* (adsbygoogle.js, «Auto ads»). Existe separado de <AdSense/> solo para mirar el
|
||||||
|
* consentimiento, que vive en el navegador (cookie `ns_cookie_consent` + evento
|
||||||
|
* `ns:consentchange`).
|
||||||
|
*
|
||||||
|
* A diferencia de la etiqueta <meta> de verificación, esto SÍ pasa por el banner:
|
||||||
|
* adsbygoogle.js escribe cookies de personalización y perfila al visitante, así que
|
||||||
|
* cargarlo sin mirar la categoría «Marketing» convertiría el banner en mentira y, con
|
||||||
|
* visitantes en la UE, en un problema legal. Mismo criterio que <MonetagScript/>.
|
||||||
|
*
|
||||||
|
* `useConsent` reacciona en caliente: entra en cuanto se acepta y deja de renderizarse
|
||||||
|
* si se revoca, sin recargar. Arranca en `false`, así que hasta confirmar el
|
||||||
|
* consentimiento no se carga nada.
|
||||||
|
*/
|
||||||
|
export function AdSenseScript({ client }: { client: string }) {
|
||||||
|
const consent = useConsent('marketing')
|
||||||
|
if (!consent) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+77
-17
@@ -87,6 +87,23 @@ export interface PosterInfo {
|
|||||||
joindate: string | null
|
joindate: string | null
|
||||||
postCount: number
|
postCount: number
|
||||||
avatar: string
|
avatar: string
|
||||||
|
charName: string | null
|
||||||
|
race: number
|
||||||
|
gender: number
|
||||||
|
classId: number
|
||||||
|
level: number
|
||||||
|
guildName: string | null
|
||||||
|
achPoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imagen de raza (CDN wowhead) según raza+género del personaje elegido.
|
||||||
|
const FORUM_RACE_SLUG: Record<number, string> = {
|
||||||
|
1: 'human', 2: 'orc', 3: 'dwarf', 4: 'nightelf', 5: 'scourge', 6: 'tauren',
|
||||||
|
7: 'gnome', 8: 'troll', 9: 'goblin', 10: 'bloodelf', 11: 'draenei', 22: 'worgen',
|
||||||
|
}
|
||||||
|
function raceAvatar(race: number, gender: number): string {
|
||||||
|
const slug = FORUM_RACE_SLUG[race] || 'human'
|
||||||
|
return `https://wow.zamimg.com/images/wow/icons/large/race_${slug}_${gender === 1 ? 'female' : 'male'}.jpg`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar por clase (FusionCMS) del personaje de más nivel de la cuenta. Solo clases
|
// Avatar por clase (FusionCMS) del personaje de más nivel de la cuenta. Solo clases
|
||||||
@@ -383,20 +400,74 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
|
|||||||
|
|
||||||
// Clase del personaje de más nivel de cada cuenta (para el avatar). Se tolera que
|
// Clase del personaje de más nivel de cada cuenta (para el avatar). Se tolera que
|
||||||
// acore_characters no exista o esté vacía: el avatar cae a guerrero por defecto.
|
// acore_characters no exista o esté vacía: el avatar cae a guerrero por defecto.
|
||||||
const classByAccount = new Map<number, number>()
|
// Personaje ALEATORIO por cuenta (nombre, raza, género, clase, nivel).
|
||||||
|
const charByAccount = new Map<number, { guid: number; name: string; race: number; gender: number; class: number; level: number }>()
|
||||||
try {
|
try {
|
||||||
const [chars] = await db(DB.characters).query<RowDataPacket[]>(
|
const [chars] = await db(DB.characters).query<RowDataPacket[]>(
|
||||||
`SELECT account, class FROM characters WHERE account IN (${placeholders}) ORDER BY level DESC, guid ASC`,
|
`SELECT account, guid, name, race, gender, class, level FROM characters WHERE account IN (${placeholders})`,
|
||||||
unique,
|
unique,
|
||||||
)
|
)
|
||||||
|
const byAcc = new Map<number, RowDataPacket[]>()
|
||||||
for (const c of chars) {
|
for (const c of chars) {
|
||||||
const acc = Number(c.account)
|
const acc = Number(c.account)
|
||||||
if (!classByAccount.has(acc)) classByAccount.set(acc, Number(c.class))
|
if (!byAcc.has(acc)) byAcc.set(acc, [])
|
||||||
|
byAcc.get(acc)!.push(c)
|
||||||
|
}
|
||||||
|
for (const [acc, list] of byAcc) {
|
||||||
|
const c = list[Math.floor(Math.random() * list.length)]
|
||||||
|
charByAccount.set(acc, {
|
||||||
|
guid: Number(c.guid), name: String(c.name), race: Number(c.race),
|
||||||
|
gender: Number(c.gender), class: Number(c.class), level: Number(c.level),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* sin personajes: avatar por defecto */
|
/* sin personajes: avatar por defecto */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hermandad y puntos de logros de los personajes elegidos.
|
||||||
|
const chosenGuids = [...charByAccount.values()].map((c) => c.guid)
|
||||||
|
const guildByGuid = new Map<number, string>()
|
||||||
|
const ptsByGuid = new Map<number, number>()
|
||||||
|
if (chosenGuids.length) {
|
||||||
|
const gp = chosenGuids.map(() => '?').join(',')
|
||||||
|
try {
|
||||||
|
const [gr] = await db(DB.characters).query<RowDataPacket[]>(
|
||||||
|
`SELECT gm.guid, g.name FROM guild_member gm JOIN guild g ON g.guildid = gm.guildid WHERE gm.guid IN (${gp})`,
|
||||||
|
chosenGuids,
|
||||||
|
)
|
||||||
|
for (const r of gr) guildByGuid.set(Number(r.guid), String(r.name))
|
||||||
|
} catch { /* */ }
|
||||||
|
try {
|
||||||
|
const [pr] = await db(DB.characters).query<RowDataPacket[]>(
|
||||||
|
`SELECT ca.guid, COALESCE(SUM(ap.points),0) AS pts
|
||||||
|
FROM character_achievement ca JOIN acore_world.achievement_points ap ON ap.id = ca.achievement
|
||||||
|
WHERE ca.guid IN (${gp}) GROUP BY ca.guid`,
|
||||||
|
chosenGuids,
|
||||||
|
)
|
||||||
|
for (const r of pr) ptsByGuid.set(Number(r.guid), Number(r.pts))
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPoster = (id: number, base: Partial<PosterInfo>): PosterInfo => {
|
||||||
|
const ch = charByAccount.get(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: base.name ?? `#${id}`,
|
||||||
|
gmlevel: base.gmlevel ?? 0,
|
||||||
|
isStaff: base.isStaff ?? false,
|
||||||
|
joindate: base.joindate ?? null,
|
||||||
|
postCount: 0,
|
||||||
|
avatar: ch ? raceAvatar(ch.race, ch.gender) : avatarForClass(undefined),
|
||||||
|
charName: ch?.name ?? null,
|
||||||
|
race: ch?.race ?? 0,
|
||||||
|
gender: ch?.gender ?? 0,
|
||||||
|
classId: ch?.class ?? 0,
|
||||||
|
level: ch?.level ?? 0,
|
||||||
|
guildName: ch ? (guildByGuid.get(ch.guid) ?? null) : null,
|
||||||
|
achPoints: ch ? (ptsByGuid.get(ch.guid) ?? 0) : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [accs] = await db(DB.auth).query<RowDataPacket[]>(
|
const [accs] = await db(DB.auth).query<RowDataPacket[]>(
|
||||||
`SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
|
`SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
|
||||||
@@ -411,15 +482,12 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
|
|||||||
|
|
||||||
for (const a of accs) {
|
for (const a of accs) {
|
||||||
const gmlevel = gm.get(Number(a.id)) ?? 0
|
const gmlevel = gm.get(Number(a.id)) ?? 0
|
||||||
map.set(Number(a.id), {
|
map.set(Number(a.id), buildPoster(Number(a.id), {
|
||||||
id: Number(a.id),
|
|
||||||
name: String(a.username || '').replace(/#\d+$/, '') || `#${a.id}`,
|
name: String(a.username || '').replace(/#\d+$/, '') || `#${a.id}`,
|
||||||
gmlevel,
|
gmlevel,
|
||||||
isStaff: gmlevel >= STAFF_GMLEVEL,
|
isStaff: gmlevel >= STAFF_GMLEVEL,
|
||||||
joindate: a.joindate ? new Date(a.joindate).toISOString() : null,
|
joindate: a.joindate ? new Date(a.joindate).toISOString() : null,
|
||||||
postCount: 0,
|
}))
|
||||||
avatar: avatarForClass(classByAccount.get(Number(a.id))),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* acore_auth no disponible: se devuelven placeholders abajo */
|
/* acore_auth no disponible: se devuelven placeholders abajo */
|
||||||
@@ -427,15 +495,7 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
|
|||||||
// Cualquier id no resuelto (cuenta borrada) recibe un placeholder.
|
// Cualquier id no resuelto (cuenta borrada) recibe un placeholder.
|
||||||
for (const id of unique) {
|
for (const id of unique) {
|
||||||
if (!map.has(id)) {
|
if (!map.has(id)) {
|
||||||
map.set(id, {
|
map.set(id, buildPoster(id, {}))
|
||||||
id,
|
|
||||||
name: `#${id}`,
|
|
||||||
gmlevel: 0,
|
|
||||||
isStaff: false,
|
|
||||||
joindate: null,
|
|
||||||
postCount: 0,
|
|
||||||
avatar: avatarForClass(classByAccount.get(id)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
|
|||||||
@@ -514,7 +514,8 @@
|
|||||||
"staff": "Staff",
|
"staff": "Staff",
|
||||||
"member": "Member",
|
"member": "Member",
|
||||||
"editPost": "Edit post",
|
"editPost": "Edit post",
|
||||||
"undelete": "Undelete"
|
"undelete": "Undelete",
|
||||||
|
"level": "Level"
|
||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"title": "Admin panel",
|
"title": "Admin panel",
|
||||||
|
|||||||
@@ -514,7 +514,8 @@
|
|||||||
"staff": "Staff",
|
"staff": "Staff",
|
||||||
"member": "Miembro",
|
"member": "Miembro",
|
||||||
"editPost": "Editar mensaje",
|
"editPost": "Editar mensaje",
|
||||||
"undelete": "Restaurar"
|
"undelete": "Restaurar",
|
||||||
|
"level": "Nivel"
|
||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"title": "Panel de administración",
|
"title": "Panel de administración",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_axe_09
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_sword_27
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_rogue_ambush
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_mace_01
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_charge
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_gouge
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_thunderclap
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_shockwave
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_sword_05
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_shadow_deathpact
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_savageblow
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_enchantarmor
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_holy_blessingofstamina
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_mirrorimage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_shield_06
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_holy_devotion
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_magic_magearmor
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_racial_bloodrage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_sunder
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_shieldbash
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_shieldwall
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_disarm
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_shadow_unholyfrenzy
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_focusedmind
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_ghoulfrenzy
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_rogue_eviscerate
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_purge
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_stoneclawtotem
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_shadow_deathscream
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_warcry
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_rogue_slicedice
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_cleave
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_axe_06
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_revenge
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_thunderbolt
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_backstab
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_decisivestrike
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_holy_ashestoashes
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_parry
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_shadow_summonimp
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_searingarrow
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_sword_20
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_sword_11
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_ancestralguardian
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_sword_48
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_weaponmastery
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_dualwield
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
spell_nature_bloodlust
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_helmet_21
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_marksmanship
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_defensivestance
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_shieldmastery
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_endlessrage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_whirlwind
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_improveddisciplines
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_racial_avatar
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_focusedrage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_rampage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_hunter_harass
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_bloodfrenzy
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_rogue_sprint
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_savageblow
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_bloodnova
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_unrelentingassault
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_offensivestance
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_trauma
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_endlessrage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_furiousresolve
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_bloodsurge
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_titansgrip
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_bladestorm
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_safeguard
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_swordandboard
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_shockwave
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_criticalblock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_incite
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_vigilance
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_rogue_hungerforblood
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_intensifyrage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_warbringer
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_shield_31
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_shieldreflection
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_heroicleap
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
inv_shoulder_22
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ability_warrior_bullrush
|
||||||
Reference in New Issue
Block a user