Files
NightSpire/web-next/lib/characters.ts
T
Inna 67faa1a266 send-gift: rediseño al original — es la tienda, pero regalando
El original lo dice en su propio texto ("los objetos disponibles son los mismos
de la tienda") y lo confirma su JS, que usa #store-list y .store-add-button: el
regalo NO tiene catálogo propio, es la tienda con el correo a otro personaje.
Nosotros teníamos una tabla aparte (home_item) con 8 objetos y precios en euros.

Ahora send-gift monta StoreBrowser en modo `gift`: mismo catálogo (3068 ítems,
PD/PV), mismo carrito con cantidades y las cuatro formas de pago. Antes NO tenía
Stripe: solo SumUp, PD y PV.

Dos pasos, como el original: primero #char-select-div (personaje de origen,
destino, confirmación y token) y solo al pulsar "Mostrar Regalos" aparece el
catálogo. Se borran SendGiftForm, /api/gift/checkout y lib/gift (ya no los usa
nadie); la tabla home_item se queda en la BD, sin usar.

Tres cosas que el usuario pidió y que estaban mal:
- El formulario va con `noValidate`: los avisos los damos nosotros en rojo
  (#show-gif-response), no el navegador.
- "Mostrar Regalos" ahora valida contra el SERVIDOR (que el personaje de origen
  sea tuyo, que el destino exista y que el token sea correcto). Antes elegías
  objetos para descubrir al final que el destino no existía.
- BUG REAL: el nombre del destino distinguía mayúsculas. `characters.name` es
  `utf8mb4_bin`, así que "innakh" NO encontraba a "Innakh" (comprobado: 0
  resultados; con COLLATE, 1). `findCharacterByName` busca sin distinguir y
  devuelve el nombre CANÓNICO, que es el que va al comando SOAP.

La validación vive en lib/gift-check y la usan las dos rutas: /api/gift/check (el
botón) y /api/gift/send, que revalida porque el cliente puede saltarse el paso 1.

También se quita del texto "El pago se realiza mediante SumUp": el original no lo
dice y además ya era falso con cuatro formas de pago.

Verificado: innakh / INNAKH / InNaKh resuelven a "Innakh"; token malo y destino
inexistente salen en rojo sin pasar al catálogo. Con 500 PD de saldo de prueba,
2 copias de un ítem de 200 pasan el cobro (deliveryFailed por el worldserver
caído, con su reembolso) y 3 dan insufficientPd: el servidor cobra por copias.
Datos de prueba borrados.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:45:01 +00:00

70 lines
2.4 KiB
TypeScript

import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { getClassCss } from './character-info'
export interface GameCharacter {
name: string
online: boolean
level: number
classCss: string // clase CSS de color por clase (priest, warrior…), para el <select>
}
/** Personajes de una cuenta de juego (acore_characters). Vacío si no está poblada. */
export async function getGameCharacters(accountId: number): Promise<GameCharacter[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT name, online, class, level FROM characters WHERE account = ?',
[accountId],
)
return rows.map((r) => ({ name: r.name, online: r.online === 1, level: Number(r.level), classCss: getClassCss(r.class) }))
} catch {
return []
}
}
/** Id de la cuenta dueña de un personaje por su nombre (único), o null. */
/**
* Busca un personaje por nombre SIN distinguir mayúsculas y devuelve su nombre
* CANÓNICO (el de la BD) además de su cuenta.
*
* El COLLATE no es adorno: `characters.name` es `utf8mb4_bin`, o sea sensible a
* mayúsculas, y sin él "innakh" no encuentra a "Innakh". Y hay que quedarse con
* el nombre de la BD porque es el que va al comando SOAP del correo.
*/
export async function findCharacterByName(
name: string,
): Promise<{ name: string; account: number } | null> {
const trimmed = name.trim()
if (!trimmed) return null
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT name, account FROM characters WHERE name = ? COLLATE utf8mb4_general_ci LIMIT 1',
[trimmed],
)
return rows[0] ? { name: String(rows[0].name), account: Number(rows[0].account) } : null
} catch {
return null
}
}
export async function getAccountIdByCharacterName(name: string): Promise<number | null> {
const trimmed = name.trim()
if (!trimmed) return null
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT account FROM characters WHERE name = ? LIMIT 1',
[trimmed],
)
return rows[0] ? Number(rows[0].account) : null
} catch {
return null
}
}
/** True si el personaje pertenece a la cuenta y está desconectado. */
export async function characterIsOfflineAndOwned(accountId: number, name: string): Promise<boolean> {
const chars = await getGameCharacters(accountId)
const c = chars.find((ch) => ch.name === name)
return Boolean(c && !c.online)
}