diff --git a/web-next/app/[locale]/[realm]/page.tsx b/web-next/app/[locale]/[realm]/page.tsx
index 302bd34..2dc3592 100644
--- a/web-next/app/[locale]/[realm]/page.tsx
+++ b/web-next/app/[locale]/[realm]/page.tsx
@@ -2,14 +2,22 @@ import type { Metadata } from 'next'
import { setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { getRealmName, realmSlug } from '@/lib/realm'
+import { RealmInfo } from '@/components/RealmInfo'
+import { PlayersBoard } from '@/components/PlayersBoard'
export const dynamic = 'force-dynamic'
-// Ruta dinámica del reino: /-realm, donde viene del nombre del
-// reino en acore_auth.realmlist (p. ej. «Trinity» → /trinity-realm).
-async function resolveRealm(param: string): Promise {
+type Kind = 'realm' | 'players'
+
+// Rutas dinámicas del reino (el slug viene de acore_auth.realmlist):
+// /-realm → información del reino (Rates/Horarios)
+// /-players → estadísticas de personajes (datos reales de la BD)
+async function resolve(param: string): Promise<{ name: string; kind: Kind } | null> {
const name = await getRealmName()
- return param === `${realmSlug(name)}-realm` ? name : null
+ const slug = realmSlug(name)
+ if (param === `${slug}-realm`) return { name, kind: 'realm' }
+ if (param === `${slug}-players`) return { name, kind: 'players' }
+ return null
}
export async function generateMetadata({
@@ -18,8 +26,9 @@ export async function generateMetadata({
params: Promise<{ realm: string }>
}): Promise {
const { realm } = await params
- const name = await resolveRealm(realm)
- return name ? { title: `Reino - ${name}` } : {}
+ const r = await resolve(realm)
+ if (!r) return {}
+ return { title: `${r.kind === 'players' ? 'Personajes' : 'Reino'} - ${r.name}` }
}
export default async function RealmPage({
@@ -29,40 +38,19 @@ export default async function RealmPage({
}) {
const { locale, realm } = await params
setRequestLocale(locale)
- const name = await resolveRealm(realm)
- if (!name) notFound()
+ const r = await resolve(realm)
+ if (!r) notFound()
+
+ const heading = r.kind === 'players' ? 'Personajes' : 'Reino'
return (
-
Reino - {name.toUpperCase()}
-
-
-
-
Rates
-
Experiencia: x8 modificable con .xp (Con Recluta a un Amigo x16)
-
Drop de objetos verdes y azules: x4
-
Drop de oro en NPCs: x2
-
Drop de oro en misiones: x1
-
Habilidades: x3
-
Profesiones: x3
-
Reputaciones: x3 (Bonus con Recluta a un amigo)
-
Honor: x3
-
-
-
Horarios
-
Los siguientes horarios son basados en la hora del reino.
-
-
Misiones diarias: 7 am
-
Reinicio de mazmorras diarias: 9 am
-
Reinicio de bandas: 9 am (Miércoles)
-
Campos de Batalla: 10 am
-
Reparto de Puntos de Arena: 11 am (Viernes)
-
-
+
{heading} - {r.name.toUpperCase()}
+ {r.kind === 'realm' ?
:
}
diff --git a/web-next/components/Header.tsx b/web-next/components/Header.tsx
index e949803..6470485 100644
--- a/web-next/components/Header.tsx
+++ b/web-next/components/Header.tsx
@@ -7,8 +7,10 @@ export async function Header() {
const loggedIn = Boolean(session.username)
// Django muestra bnet_email si existe, si no el username.
const accountLabel = session.bnetEmail || session.username || ''
- // Enlace al reino según el nombre en realmlist (p. ej. /trinity-realm).
- const realmHref = `/${realmSlug(await getRealmName())}-realm`
+ // Enlaces al reino/jugadores según el nombre en realmlist (p. ej. /trinity-realm, /trinity-players).
+ const slug = realmSlug(await getRealmName())
+ const realmHref = `/${slug}-realm`
+ const playersHref = `/${slug}-players`
- return
+ return
}
diff --git a/web-next/components/PlayersBoard.tsx b/web-next/components/PlayersBoard.tsx
new file mode 100644
index 0000000..ca2e078
--- /dev/null
+++ b/web-next/components/PlayersBoard.tsx
@@ -0,0 +1,146 @@
+import {
+ getPlayersData,
+ CLASS_INFO,
+ raceBig,
+ classMedium,
+ classChest,
+ ALLIANCE_ICON,
+ HORDE_ICON,
+} from '@/lib/players'
+
+/* eslint-disable @next/next/no-img-element */
+
+export async function PlayersBoard() {
+ const data = await getPlayersData()
+
+ return (
+ <>
+
+
+
La información de esta página se actualiza 1 vez al día automáticamente
+
+
+
+ {/* Personajes creados por clase */}
+
+
Personajes creados por clase
+
+
+ {data.classStats.map((s) => (
+
+
+
+ ![{CLASS_INFO[s.id].label}]({classChest(s.id)}) |
+ | Total: {s.total} |
+
|
+ Alianzas: {s.alliance} |
+ Hordas: {s.horde} |
+
+
+
+ ))}
+
+
+
+ | Personajes totales: {data.totals.total} |
+ Personajes alianzas: {data.totals.alliance} |
+ Personajes hordas: {data.totals.horde} |
+
+
+
+
+
+
+
+
+
+ {/* Primeros del Reino - Nivel 80 */}
+
+
Primeros del Reino - Nivel 80
+
+ {data.firsts.length === 0 ? (
+
Aún no hay personajes de nivel 80 registrados.
+ ) : (
+
+
+
+ | Logro |
+ Personaje |
+ Fecha |
+
+ {data.firsts.map((f, i) => (
+
+
+ {' '}
+ ¡Primero del reino! {f.label}
+ |
+ {f.name} |
+ {f.date} |
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {/* Top de logros */}
+
+
Top de logros
+
+
Los 10 jugadores con más logros
+
+
+
+ | Nombre |
+ Raza |
+ Clase |
+ Logros |
+
+ {data.topAch.map((r, i) => (
+
+ | {r.name} |
+  |
+ }) |
+ {r.value} |
+
+ ))}
+
+
+
+
+
+
+
+ {/* Top de muertes con honor */}
+
+
Top de muertes con honor
+
+
Los 10 jugadores con más muertes con honor
+
+
+
+ | Nombre |
+ Raza |
+ Clase |
+ Total de muertes |
+ Total de muertes hoy |
+
+ {data.topHonor.map((r, i) => (
+
+ | {r.name} |
+  |
+ }) |
+ {r.total} |
+ {r.today} |
+
+ ))}
+
+
+
+
+ >
+ )
+}
diff --git a/web-next/components/RealmInfo.tsx b/web-next/components/RealmInfo.tsx
new file mode 100644
index 0000000..1d004ec
--- /dev/null
+++ b/web-next/components/RealmInfo.tsx
@@ -0,0 +1,29 @@
+/** Información del reino (Rates + Horarios) — contenido de /-realm. */
+export function RealmInfo() {
+ return (
+
+
+
Rates
+
Experiencia: x8 modificable con .xp (Con Recluta a un Amigo x16)
+
Drop de objetos verdes y azules: x4
+
Drop de oro en NPCs: x2
+
Drop de oro en misiones: x1
+
Habilidades: x3
+
Profesiones: x3
+
Reputaciones: x3 (Bonus con Recluta a un amigo)
+
Honor: x3
+
+
+
Horarios
+
Los siguientes horarios son basados en la hora del reino.
+
+
Misiones diarias: 7 am
+
Reinicio de mazmorras diarias: 9 am
+
Reinicio de bandas: 9 am (Miércoles)
+
Campos de Batalla: 10 am
+
Reparto de Puntos de Arena: 11 am (Viernes)
+
+
+
+ )
+}
diff --git a/web-next/components/SiteHeader.tsx b/web-next/components/SiteHeader.tsx
index 4939b88..8731d2e 100644
--- a/web-next/components/SiteHeader.tsx
+++ b/web-next/components/SiteHeader.tsx
@@ -17,10 +17,12 @@ export function SiteHeader({
loggedIn,
accountLabel,
realmHref,
+ playersHref,
}: {
loggedIn: boolean
accountLabel: string
realmHref: string
+ playersHref: string
}) {
const router = useRouter()
const [open, setOpen] = useState(false)
@@ -89,7 +91,7 @@ export function SiteHeader({
{loggedIn && (
- JUGADORES
+ JUGADORES
)}
diff --git a/web-next/lib/players.ts b/web-next/lib/players.ts
new file mode 100644
index 0000000..201cbfc
--- /dev/null
+++ b/web-next/lib/players.ts
@@ -0,0 +1,139 @@
+import type { RowDataPacket } from 'mysql2'
+import { db, DB } from './db'
+
+const IMG = '/nw-themes/nw-ryu/nw-images'
+
+// Clases (orden del original) → slug de color + etiqueta.
+export const CLASS_ORDER = [1, 2, 3, 4, 5, 6, 7, 8, 9, 11]
+export const CLASS_INFO: Record = {
+ 1: { key: 'warrior', label: 'Guerrero' },
+ 2: { key: 'paladin', label: 'Paladín' },
+ 3: { key: 'hunter', label: 'Cazador' },
+ 4: { key: 'rogue', label: 'Pícaro' },
+ 5: { key: 'priest', label: 'Sacerdote' },
+ 6: { key: 'death-knight', label: 'C. de la M.' },
+ 7: { key: 'shaman', label: 'Chamán' },
+ 8: { key: 'mage', label: 'Mago' },
+ 9: { key: 'warlock', label: 'Brujo' },
+ 11: { key: 'druid', label: 'Druida' },
+}
+// Razas → slug + etiqueta + facción. Orden del original para «Primeros del reino».
+export const RACE_ORDER = [7, 10, 11, 3, 1, 4, 2, 6, 8, 5]
+export const RACE_INFO: Record = {
+ 1: { key: 'human', label: 'Humano', faction: 'alliance' },
+ 2: { key: 'orc', label: 'Orco', faction: 'horde' },
+ 3: { key: 'dwarf', label: 'Enano', faction: 'alliance' },
+ 4: { key: 'night-elf', label: 'Elfo de la noche', faction: 'alliance' },
+ 5: { key: 'undead', label: 'No-muerto', faction: 'horde' },
+ 6: { key: 'tauren', label: 'Tauren', faction: 'horde' },
+ 7: { key: 'gnome', label: 'Gnomo', faction: 'alliance' },
+ 8: { key: 'troll', label: 'Trol', faction: 'horde' },
+ 10: { key: 'blood-elf', label: 'Elfo de sangre', faction: 'horde' },
+ 11: { key: 'draenei', label: 'Draenei', faction: 'alliance' },
+}
+
+export function classChest(id: number) {
+ return `${IMG}/nw-classes/${CLASS_INFO[id]?.key ?? 'warrior'}-chest.webp`
+}
+export function classMedium(id: number) {
+ return `${IMG}/nw-classes/${CLASS_INFO[id]?.key ?? 'warrior'}-medium.jpg`
+}
+export function raceBig(race: number, gender: number) {
+ return `${IMG}/nw-races/big-${RACE_INFO[race]?.key ?? 'human'}-${gender === 0 ? 'male' : 'female'}.webp`
+}
+export const ALLIANCE_ICON = `${IMG}/nw-icons/alliance-no-border.webp`
+export const HORDE_ICON = `${IMG}/nw-icons/horde-no-border.webp`
+export const ACH80_ICON = `${IMG}/nw-icons/achievement-level-80.jpg`
+
+// Logro de «Nivel 80» (secuencia estándar de logros de nivel de WotLK: 6=10 … 20=80).
+const ACH_LEVEL80 = 20
+
+export interface ClassStat { id: number; key: string; total: number; alliance: number; horde: number }
+export interface FirstEntry { icon: string; label: string; name: string; classKey: string; date: string }
+export interface AchRow { name: string; race: number; class: number; classKey: string; value: number }
+export interface HonorRow { name: string; race: number; class: number; classKey: string; total: number; today: number }
+
+export interface PlayersData {
+ classStats: ClassStat[]
+ totals: { total: number; alliance: number; horde: number }
+ firsts: FirstEntry[]
+ topAch: AchRow[]
+ topHonor: HonorRow[]
+}
+
+function fmtTs(ts: number): string {
+ const d = new Date(ts * 1000)
+ const p = (n: number) => String(n).padStart(2, '0')
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()}`
+}
+
+export async function getPlayersData(): Promise {
+ const c = db(DB.characters)
+
+ // 1) Personajes creados por clase (y facción).
+ const classStats: ClassStat[] = CLASS_ORDER.map((id) => ({ id, key: CLASS_INFO[id].key, total: 0, alliance: 0, horde: 0 }))
+ const totals = { total: 0, alliance: 0, horde: 0 }
+ try {
+ const [rows] = await c.query('SELECT class, race, COUNT(*) AS n FROM characters GROUP BY class, race')
+ for (const r of rows) {
+ const stat = classStats.find((s) => s.id === r.class)
+ const n = Number(r.n)
+ const faction = RACE_INFO[r.race]?.faction
+ totals.total += n
+ if (faction === 'alliance') totals.alliance += n
+ else if (faction === 'horde') totals.horde += n
+ if (stat) {
+ stat.total += n
+ if (faction === 'alliance') stat.alliance += n
+ else if (faction === 'horde') stat.horde += n
+ }
+ }
+ } catch { /* BD de personajes puede no estar poblada */ }
+
+ // 2) Primeros del reino - Nivel 80 (logro 20, por fecha).
+ const firsts: FirstEntry[] = []
+ try {
+ const [rows] = await c.query(
+ `SELECT ca.date AS date, ch.name AS name, ch.class AS class, ch.race AS race, ch.gender AS gender
+ FROM character_achievement ca JOIN characters ch ON ch.guid = ca.guid
+ WHERE ca.achievement = ? ORDER BY ca.date ASC`,
+ [ACH_LEVEL80],
+ )
+ if (rows.length) {
+ const overall = rows[0]
+ firsts.push({ icon: ACH80_ICON, label: 'Nivel 80', name: overall.name, classKey: CLASS_INFO[overall.class]?.key ?? '', date: fmtTs(Number(overall.date)) })
+ // Primero por clase.
+ for (const id of CLASS_ORDER) {
+ const row = rows.find((r) => r.class === id)
+ if (row) firsts.push({ icon: classMedium(id), label: `${CLASS_INFO[id].label} nivel 80`, name: row.name, classKey: CLASS_INFO[id].key, date: fmtTs(Number(row.date)) })
+ }
+ // Primero por raza.
+ for (const race of RACE_ORDER) {
+ const row = rows.find((r) => r.race === race)
+ if (row) firsts.push({ icon: raceBig(race, Number(row.gender)), label: `${RACE_INFO[race].label} nivel 80`, name: row.name, classKey: CLASS_INFO[row.class]?.key ?? '', date: fmtTs(Number(row.date)) })
+ }
+ }
+ } catch { /* ignore */ }
+
+ // 3) Top de logros (nº de logros; los «puntos» no están en la BD del servidor).
+ let topAch: AchRow[] = []
+ try {
+ const [rows] = await c.query(
+ `SELECT ch.name AS name, ch.race AS race, ch.class AS class, COUNT(*) AS n
+ FROM character_achievement ca JOIN characters ch ON ch.guid = ca.guid
+ GROUP BY ca.guid, ch.name, ch.race, ch.class ORDER BY n DESC LIMIT 10`,
+ )
+ topAch = rows.map((r) => ({ name: r.name, race: r.race, class: r.class, classKey: CLASS_INFO[r.class]?.key ?? '', value: Number(r.n) }))
+ } catch { /* ignore */ }
+
+ // 4) Top de muertes con honor.
+ let topHonor: HonorRow[] = []
+ try {
+ const [rows] = await c.query(
+ 'SELECT name, race, class, totalKills, todayKills FROM characters ORDER BY totalKills DESC LIMIT 10',
+ )
+ topHonor = rows.map((r) => ({ name: r.name, race: r.race, class: r.class, classKey: CLASS_INFO[r.class]?.key ?? '', total: Number(r.totalKills), today: Number(r.todayKills) }))
+ } catch { /* ignore */ }
+
+ return { classStats, totals, firsts, topAch, topHonor }
+}