diff --git a/web-next/app/[locale]/restore-character/page.tsx b/web-next/app/[locale]/restore-character/page.tsx new file mode 100644 index 0000000..6e223f4 --- /dev/null +++ b/web-next/app/[locale]/restore-character/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getRestorableCharacters } from '@/lib/restore-character' +import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' +import { RestoreCharacterForm } from '@/components/RestoreCharacterForm' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Recuperar personaje' } + +export default async function RestoreCharacterPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const chars = await getRestorableCharacters(session.accountId!) + + return ( + + +
+

+ La herramienta Recuperar personaje te permite restaurar un personaje que haya sido borrado. +

+
+

Sólo disponible para personajes que cumplan las siguientes dos condiciones:

+

- Nivel superior a 60.

+

- Borrado en un período menor a 30 días.

+

Para personajes Caballeros de la Muerte:

+

- No es posible recuperar un personaje Caballero de la Muerte si ya hay otro activo en la cuenta.

+
+

Cuando ingreses a la lista de personajes de tu cuenta, verás al personaje recuperado.

+
+
+ NOTA +
+

Personajes borrados que no cumplan dichas condiciones ya no son recuperables.

+

Se puede repetir la acción en un mismo personaje cada 12 horas.

+
+
+ + +
+
+ ) +} diff --git a/web-next/app/[locale]/restore-items/page.tsx b/web-next/app/[locale]/restore-items/page.tsx new file mode 100644 index 0000000..2fec1e9 --- /dev/null +++ b/web-next/app/[locale]/restore-items/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getGameCharacters } from '@/lib/characters' +import { getDPointsBalance } from '@/lib/dpoints' +import { RESTORE_ITEM_PD } from '@/lib/restore-items' +import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' +import { RestoreItemsForm } from '@/components/RestoreItemsForm' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Recuperar ítems' } + +export default async function RestoreItemsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const [chars, balance] = await Promise.all([getGameCharacters(session.accountId!), getDPointsBalance(session.accountId!)]) + + return ( + + +
+

+ La herramienta Recuperar ítems te permite recuperar ítems que hayan sido borrados. +

+
+

Sólo disponible para ítems que cumplan las siguientes condiciones:

+

+ - De calidad: Raro | Épico |{' '} + Legendaria | Artefacto | Reliquia. +

+

- Equipable.

+

- Se liga al recogerlo.

+

- Borrado en un período menor a 7 días.

+
+
+ NOTA +
+

Ítems borrados que no cumplan dichas condiciones ya no son recuperables.

+

Se pueden consultar ítems borrados en un mismo personaje cada 8 horas.

+
+
+ +
+
+
+

Cada ítem requiere {RESTORE_ITEM_PD} PD

+
+ ({ name: c.name, classCss: c.classCss }))} + canAfford={balance >= RESTORE_ITEM_PD} + /> +
+
+
+ ) +} diff --git a/web-next/app/api/character/restore-items/restore/route.ts b/web-next/app/api/character/restore-items/restore/route.ts new file mode 100644 index 0000000..4e6533a --- /dev/null +++ b/web-next/app/api/character/restore-items/restore/route.ts @@ -0,0 +1,17 @@ +import { getSession } from '@/lib/session' +import { restoreItem } from '@/lib/restore-items' + +/** Restaura un ítem borrado (cuesta 100 PD). */ +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId || !session.username) { + return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + } + let body: Record = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + return Response.json(await restoreItem(session.accountId, String(body.character ?? '').trim(), Number(body.recoverId))) +} diff --git a/web-next/app/api/character/restore-items/search/route.ts b/web-next/app/api/character/restore-items/search/route.ts new file mode 100644 index 0000000..d13b114 --- /dev/null +++ b/web-next/app/api/character/restore-items/search/route.ts @@ -0,0 +1,22 @@ +import { getSession } from '@/lib/session' +import { verifyTurnstile } from '@/lib/turnstile' +import { listDeletedItems } from '@/lib/restore-items' + +/** Lista los ítems borrados recuperables de un personaje (cooldown 8 h). */ +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId || !session.username) { + return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + } + let body: Record = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || undefined + if (!(await verifyTurnstile(String(body.turnstile ?? ''), ip))) { + return Response.json({ success: false, error: 'captcha' }) + } + return Response.json(await listDeletedItems(session.accountId, String(body.character ?? '').trim())) +} diff --git a/web-next/app/api/character/restore/route.ts b/web-next/app/api/character/restore/route.ts new file mode 100644 index 0000000..2897f4b --- /dev/null +++ b/web-next/app/api/character/restore/route.ts @@ -0,0 +1,17 @@ +import { getSession } from '@/lib/session' +import { restoreCharacter } from '@/lib/restore-character' + +/** Recupera un personaje borrado de la cuenta en sesión (si cumple las condiciones). */ +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId || !session.username) { + return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + } + let body: Record = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + return Response.json(await restoreCharacter(session.accountId, Number(body.guid))) +} diff --git a/web-next/components/RestoreCharacterForm.tsx b/web-next/components/RestoreCharacterForm.tsx new file mode 100644 index 0000000..68c1835 --- /dev/null +++ b/web-next/components/RestoreCharacterForm.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from '@/i18n/navigation' +import type { DeletedCharacter } from '@/lib/restore-character' + +function restoreError(error?: string): string { + switch (error) { + case 'notFound': + return 'Ese personaje borrado no es de tu cuenta.' + case 'notDeleted': + return 'Ese personaje no está borrado.' + case 'levelTooLow': + return 'Solo se pueden recuperar personajes de nivel superior a 60.' + case 'tooOld': + return 'El personaje se borró hace más de 30 días y ya no es recuperable.' + case 'dkExists': + return 'No puedes recuperar un Caballero de la Muerte: ya tienes uno activo en la cuenta.' + case 'nameTaken': + return 'El nombre original ya está en uso por otro personaje.' + case 'cooldown': + return 'Solo puedes intentar recuperar el mismo personaje una vez cada 12 horas.' + case 'notAuthenticated': + return 'Tu sesión ha expirado. Inicia sesión de nuevo.' + default: + return 'No se pudo recuperar el personaje. Inténtalo de nuevo.' + } +} + +export function RestoreCharacterForm({ characters }: { characters: DeletedCharacter[] }) { + const router = useRouter() + const [guid, setGuid] = useState('') + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + const selectedCss = characters.find((c) => String(c.guid) === guid)?.classCss + + if (characters.length === 0) { + return ( +
+
+
+ No hay personajes +
+
+ ) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy || done || !guid) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/character/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ guid: Number(guid) }), + }) + const data: { success?: boolean; error?: string; name?: string } = await res.json() + if (data.success) { + setDone(true) + setMessage({ ok: true, text: `¡Personaje "${data.name}" recuperado! Lo verás al entrar al reino.` }) + setTimeout(() => router.refresh(), 3000) + } else { + setMessage({ ok: false, text: restoreError(data.error) }) + } + } catch { + setMessage({ ok: false, text: restoreError() }) + } finally { + setBusy(false) + } + } + + return ( +
+
+ +
+ +
+
+
+ {message && {message.text}} +
+
+ ) +} diff --git a/web-next/components/RestoreItemsForm.tsx b/web-next/components/RestoreItemsForm.tsx new file mode 100644 index 0000000..b3295c0 --- /dev/null +++ b/web-next/components/RestoreItemsForm.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useState } from 'react' +import { CharacterSelect, type CharOption } from '@/components/CharacterSelect' +import { Turnstile } from '@/components/Turnstile' + +const ITEM_DB = 'https://wotlk.novawow.com' + +interface DeletedItem { + recoverId: number + itemId: number + name: string + count: number +} + +function itemsError(error?: string, minutes?: number): string { + switch (error) { + case 'characterNotOwned': + return 'El personaje seleccionado no es válido.' + case 'cooldown': + return `Solo puedes consultar los ítems de este personaje cada 8 horas (faltan ${minutes ?? 0} min).` + case 'soapError': + return 'No se pudo conectar con el servidor de juego. Inténtalo más tarde.' + case 'insufficientPoints': + return 'No tienes suficientes PD (se requieren 100 por ítem).' + case 'captcha': + return 'Verifica que no eres un robot.' + case 'notAuthenticated': + return 'Tu sesión ha expirado. Inicia sesión de nuevo.' + default: + return 'Algo ha salido mal. Inténtalo de nuevo.' + } +} + +export function RestoreItemsForm({ characters, canAfford }: { characters: CharOption[]; canAfford: boolean }) { + const [character, setCharacter] = useState('') + const [captcha, setCaptcha] = useState('') + const [captchaKey, setCaptchaKey] = useState(0) + const [busy, setBusy] = useState(false) + const [items, setItems] = useState(null) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + if (!canAfford) { + return ( +
+ No tienes PD suficientes +
+ ) + } + + async function search(e: React.FormEvent) { + e.preventDefault() + if (busy || !character) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/character/restore-items/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ character, turnstile: captcha }), + }) + const data: { success?: boolean; error?: string; minutes?: number; items?: DeletedItem[] } = await res.json() + if (data.success) { + setItems(data.items ?? []) + if ((data.items ?? []).length === 0) setMessage({ ok: true, text: 'Este personaje no tiene ítems borrados recuperables.' }) + } else { + setMessage({ ok: false, text: itemsError(data.error, data.minutes) }) + } + } catch { + setMessage({ ok: false, text: itemsError() }) + } finally { + setBusy(false) + setCaptcha('') + setCaptchaKey((k) => k + 1) + } + } + + async function restore(item: DeletedItem) { + if (busy) return + if (!window.confirm(`¿Recuperar "${item.name}" por 100 PD?`)) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/character/restore-items/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ character, recoverId: item.recoverId }), + }) + const data: { success?: boolean; error?: string } = await res.json() + if (data.success) { + setItems((prev) => (prev ? prev.filter((i) => i.recoverId !== item.recoverId) : prev)) + setMessage({ ok: true, text: `¡"${item.name}" recuperado! Se han descontado 100 PD.` }) + } else { + setMessage({ ok: false, text: itemsError(data.error) }) + } + } catch { + setMessage({ ok: false, text: itemsError() }) + } finally { + setBusy(false) + } + } + + function reset() { + setItems(null) + setMessage(null) + setCharacter('') + setCaptcha('') + setCaptchaKey((k) => k + 1) + } + + return ( +
+ {items === null ? ( +
+
+ +
+ + + +
+ ) : ( +
+

Ítems borrados de {character}

+ {items.length === 0 ? ( +

No hay ítems borrados recuperables.

+ ) : ( + + + {items.map((it) => ( + + + + + ))} + +
+ {it.name} + {it.count > 1 ? ` x${it.count}` : ''} + + +
+ )} +
+

{ e.preventDefault(); reset() }}>Consultar otro personaje

+
+ )} +
+
+ {message && {message.text}} +
+
+ ) +} diff --git a/web-next/lib/restore-character.ts b/web-next/lib/restore-character.ts new file mode 100644 index 0000000..d05d2b3 --- /dev/null +++ b/web-next/lib/restore-character.ts @@ -0,0 +1,112 @@ +import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import { db, DB } from './db' +import { getClassCss } from './character-info' + +/** + * Recuperar personaje (/restore-character): restaura un personaje borrado. + * En AzerothCore un personaje borrado queda en `characters` con account=0, + * deleteInfos_Account/deleteInfos_Name = dueño/nombre originales y deleteDate + * (unix, segundos). Condiciones: nivel > 60, borrado hace < 30 días, y para + * Caballeros de la Muerte, la cuenta no puede tener ya un DK activo. 12 h/personaje. + */ + +const MAX_AGE_S = 30 * 24 * 60 * 60 +const COOLDOWN_MS = 12 * 60 * 60 * 1000 + +export interface DeletedCharacter { + guid: number + name: string + level: number + classCss: string +} + +/** Personajes borrados de la cuenta que cumplen las condiciones (nivel>60, <30 días). */ +export async function getRestorableCharacters(accountId: number): Promise { + if (!accountId) return [] + try { + const nowS = Math.floor(Date.now() / 1000) + const [rows] = await db(DB.characters).query( + 'SELECT guid, deleteInfos_Name AS name, level, class FROM characters ' + + 'WHERE deleteInfos_Account = ? AND deleteDate IS NOT NULL AND deleteDate <> 0 ' + + 'AND level > 60 AND deleteDate > ? ORDER BY deleteDate DESC', + [accountId, nowS - MAX_AGE_S], + ) + return rows.map((r) => ({ guid: Number(r.guid), name: r.name, level: r.level, classCss: getClassCss(r.class) })) + } catch { + return [] + } +} + +export interface Result { + success: boolean + error?: string + name?: string +} + +export async function restoreCharacter(accountId: number, guid: number): Promise { + if (!accountId || !Number.isInteger(guid) || guid <= 0) return { success: false, error: 'invalidRequest' } + + let name = '' + let cls = 0 + try { + const [rows] = await db(DB.characters).query( + 'SELECT deleteInfos_Name AS name, deleteInfos_Account, deleteDate, level, class FROM characters WHERE guid = ? LIMIT 1', + [guid], + ) + const c = rows[0] + if (!c || Number(c.deleteInfos_Account) !== accountId) return { success: false, error: 'notFound' } + if (!c.deleteDate || Number(c.deleteDate) === 0) return { success: false, error: 'notDeleted' } + if (Number(c.level) <= 60) return { success: false, error: 'levelTooLow' } + if (Number(c.deleteDate) < Math.floor(Date.now() / 1000) - MAX_AGE_S) return { success: false, error: 'tooOld' } + name = String(c.name) + cls = Number(c.class) + } catch { + return { success: false, error: 'genericError' } + } + + // Caballero de la Muerte: no restaurar si ya hay un DK activo en la cuenta. + if (cls === 6) { + const [dk] = await db(DB.characters).query( + 'SELECT 1 FROM characters WHERE account = ? AND class = 6 AND (deleteDate IS NULL OR deleteDate = 0) LIMIT 1', + [accountId], + ) + if (dk[0]) return { success: false, error: 'dkExists' } + } + + // El nombre original debe estar libre (otro personaje activo podría tenerlo). + const [taken] = await db(DB.characters).query( + 'SELECT 1 FROM characters WHERE name = ? AND (deleteDate IS NULL OR deleteDate = 0) LIMIT 1', + [name], + ) + if (taken[0]) return { success: false, error: 'nameTaken' } + + // Cooldown de 12 h por personaje. + try { + const [last] = await db(DB.default).query( + 'SELECT used_at FROM home_restorehistory WHERE character_guid = ? ORDER BY used_at DESC LIMIT 1', + [guid], + ) + if (last[0] && Date.now() - new Date(last[0].used_at).getTime() < COOLDOWN_MS) { + return { success: false, error: 'cooldown' } + } + } catch { + /* ignore */ + } + + // Restaura (igual que `.character deleted restore` del core). + try { + const [res] = await db(DB.characters).query( + "UPDATE characters SET account = ?, name = ?, deleteInfos_Account = 0, deleteInfos_Name = '', deleteDate = NULL " + + 'WHERE guid = ? AND deleteInfos_Account = ? AND deleteDate IS NOT NULL AND deleteDate <> 0', + [accountId, name, guid, accountId], + ) + if (res.affectedRows !== 1) return { success: false, error: 'genericError' } + } catch { + return { success: false, error: 'genericError' } + } + + await db(DB.default) + .query('INSERT INTO home_restorehistory (character_guid, used_at) VALUES (?, NOW())', [guid]) + .catch(() => {}) + return { success: true, name } +} diff --git a/web-next/lib/restore-items.ts b/web-next/lib/restore-items.ts new file mode 100644 index 0000000..369514f --- /dev/null +++ b/web-next/lib/restore-items.ts @@ -0,0 +1,112 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { executeSoapCommand } from './soap' +import { getGameCharacters } from './characters' + +/** + * Recuperar ítems (/restore-items): usa el sistema de recuperación de AzerothCore + * por SOAP. `.item restore list ` lista los ítems borrados recuperables (el + * core aplica las condiciones: calidad Raro+, equipable, se liga al recoger, + * borrado < 7 días). `.item restore ` restaura uno. Cada + * restauración cuesta **100 PD**. La consulta tiene cooldown de 8 h por personaje. + */ + +export const RESTORE_ITEM_PD = 100 +const SEARCH_COOLDOWN_MS = 8 * 60 * 60 * 1000 +const SAFE_NAME = /^[A-Za-z]{1,12}$/ + +export interface DeletedItem { + recoverId: number + itemId: number + name: string + count: number +} + +/** Parsea la salida de `.item restore list` (líneas "Recover id: X | Item: NAME (ID) | Count: N"). */ +export function parseRecoverList(soap: string): DeletedItem[] { + const items: DeletedItem[] = [] + const re = /Recover id:\s*(\d+)\s*\|\s*Item:\s*(.+?)\s*\((\d+)\)\s*\|\s*Count:\s*(\d+)/g + let m: RegExpExecArray | null + while ((m = re.exec(soap)) !== null) { + items.push({ recoverId: Number(m[1]), name: m[2].trim(), itemId: Number(m[3]), count: Number(m[4]) }) + } + return items +} + +async function ownsCharacter(accountId: number, character: string): Promise { + if (!SAFE_NAME.test(character)) return false + const chars = await getGameCharacters(accountId) + return chars.some((c) => c.name === character) +} + +export interface ListResult { + success: boolean + error?: string + minutes?: number + items?: DeletedItem[] +} + +/** Lista los ítems borrados recuperables de un personaje (cooldown 8 h). */ +export async function listDeletedItems(accountId: number, character: string): Promise { + if (!(await ownsCharacter(accountId, character))) return { success: false, error: 'characterNotOwned' } + + // Cooldown de 8 h por personaje. + try { + const [last] = await db(DB.default).query( + 'SELECT used_at FROM home_itemsearchhistory WHERE character_name = ? ORDER BY used_at DESC LIMIT 1', + [character], + ) + if (last[0]) { + const diff = Date.now() - new Date(last[0].used_at).getTime() + if (diff < SEARCH_COOLDOWN_MS) { + return { success: false, error: 'cooldown', minutes: Math.ceil((SEARCH_COOLDOWN_MS - diff) / 60000) } + } + } + } catch { + /* ignore */ + } + + const soap = await executeSoapCommand(`.item restore list ${character}`) + if (soap === null) return { success: false, error: 'soapError' } + + await db(DB.default) + .query('INSERT INTO home_itemsearchhistory (character_name, used_at) VALUES (?, NOW())', [character]) + .catch(() => {}) + return { success: true, items: parseRecoverList(soap) } +} + +export interface RestoreResult { + success: boolean + error?: string + name?: string +} + +/** Restaura un ítem borrado (cuesta 100 PD; reembolsa si el SOAP falla). */ +export async function restoreItem(accountId: number, character: string, recoverId: number): Promise { + if (!(await ownsCharacter(accountId, character))) return { success: false, error: 'characterNotOwned' } + if (!Number.isInteger(recoverId) || recoverId <= 0) return { success: false, error: 'invalidRequest' } + + // Cobra 100 PD de forma atómica. + let charged = false + try { + const [res] = await db(DB.default).query( + 'UPDATE home_api_points SET dp = dp - ? WHERE accountID = ? AND dp >= ?', + [RESTORE_ITEM_PD, accountId, RESTORE_ITEM_PD], + ) + // @ts-expect-error affectedRows + charged = res.affectedRows === 1 + } catch { + return { success: false, error: 'genericError' } + } + if (!charged) return { success: false, error: 'insufficientPoints' } + + const soap = await executeSoapCommand(`.item restore ${recoverId} ${character}`) + if (soap === null) { + await db(DB.default) + .query('UPDATE home_api_points SET dp = dp + ? WHERE accountID = ?', [RESTORE_ITEM_PD, accountId]) + .catch(() => {}) + return { success: false, error: 'soapError' } + } + + return { success: true } +} diff --git a/web-next/sql/home_itemsearchhistory.sql b/web-next/sql/home_itemsearchhistory.sql new file mode 100644 index 0000000..95aace9 --- /dev/null +++ b/web-next/sql/home_itemsearchhistory.sql @@ -0,0 +1,8 @@ +-- Cooldown de "consultar ítems borrados" (/restore-items): 8 h por personaje. +CREATE TABLE IF NOT EXISTS home_itemsearchhistory ( + id BIGINT NOT NULL AUTO_INCREMENT, + character_name VARCHAR(12) NOT NULL, + used_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_char (character_name, used_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/web-next/sql/home_restorehistory.sql b/web-next/sql/home_restorehistory.sql new file mode 100644 index 0000000..6baf9aa --- /dev/null +++ b/web-next/sql/home_restorehistory.sql @@ -0,0 +1,8 @@ +-- Cooldown de Recuperar personaje (/restore-character): 12 h por personaje (guid). +CREATE TABLE IF NOT EXISTS home_restorehistory ( + id BIGINT NOT NULL AUTO_INCREMENT, + character_guid BIGINT NOT NULL, + used_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_guid (character_guid, used_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;