import crypto from 'node:crypto' import type { RowDataPacket, ResultSetHeader } from 'mysql2' import { db, DB } from './db' import { authenticate } from './auth' import { checkSecurityToken } from './security-token' import { transferDPoints } from './dpoints' import { executeSoapCommand } from './soap' /** * Comercio de PD: códigos de 1 solo uso que valen X PD a cambio de X oro. * * - El vendedor crea un código (VENTA) indicando PD, oro y el personaje de SU * cuenta que recibirá el oro por correo cuando se canjee. * - El comprador canjea el código (COMPRA) indicando de qué personaje de SU * cuenta se cobra el oro. * * Al canjear (decisión de diseño «al canjear», no escrow): * 1. Se descuenta el oro del personaje del comprador (debe estar desconectado). * 2. Los PD pasan de la cuenta del creador a la del comprador. * 3. El oro se envía por correo (SOAP) al personaje del creador. * Cada código dura 1 hora. Tras un canje correcto, la cuenta del comprador queda * «bloqueada» 5 s (no puede canjear otro código) como medida de seguridad. */ const CODE_TTL_MS = 60 * 60 * 1000 // 1 hora const REDEEM_LOCK_SECONDS = 5 const GOLD_TO_COPPER = 10000 const MAX_AMOUNT = 99999 // el input admite máx. 5 dígitos /** Nombres de personaje válidos (evita inyección en comandos SOAP). */ const NAME_RE = /^[A-Za-z]{1,12}$/ export interface Result { success: boolean error?: string } export interface TradeCodeInfo { code: string points: number gold: number expiresAt: string } function validAmount(n: number): boolean { return Number.isInteger(n) && n >= 1 && n <= MAX_AMOUNT } /** Genera un código único legible de <=25 caracteres (p.ej. TP-3F9K2A7QX8B1). */ function generateCode(): string { return 'TP-' + crypto.randomBytes(9).toString('hex').toUpperCase() // "TP-" + 18 hex = 21 } /** Marca como expirados los códigos activos cuyo plazo venció (limpieza perezosa). */ async function expireOldCodes(): Promise { try { await db(DB.default).query( "UPDATE tradecode SET status = 'expired' WHERE status = 'active' AND expires_at <= NOW()", ) } catch { /* ignore */ } } /** True si el personaje pertenece a la cuenta. Devuelve el saldo (copper) si sí. */ async function characterMoney(accountId: number, name: string): Promise<{ money: number; online: boolean } | null> { const [rows] = await db(DB.characters).query( 'SELECT money, online FROM characters WHERE name = ? AND account = ? LIMIT 1', [name, accountId], ) if (!rows[0]) return null return { money: Number(rows[0].money), online: rows[0].online === 1 } } /* -------------------------------- VENTA ----------------------------------- */ export async function createTradeCode(params: { creatorAccount: number creatorEmail: string creatorCharacter: string points: number gold: number password: string token: string turnstileOk: boolean }): Promise<{ success: boolean; error?: string; code?: TradeCodeInfo }> { const { creatorAccount, creatorEmail, creatorCharacter, points, gold, password, token, turnstileOk } = params if (!creatorCharacter || !password || !token) return { success: false, error: 'missingFields' } if (!validAmount(points) || !validAmount(gold)) return { success: false, error: 'invalidAmount' } if (!NAME_RE.test(creatorCharacter)) return { success: false, error: 'characterNotFound' } if (!turnstileOk) return { success: false, error: 'captcha' } // El personaje que recibirá el oro debe ser de la cuenta del creador. const owned = await characterMoney(creatorAccount, creatorCharacter) if (!owned) return { success: false, error: 'characterNotOwned' } if (!(await authenticate(creatorEmail, password))) return { success: false, error: 'wrongPassword' } if (!(await checkSecurityToken(creatorAccount, token))) return { success: false, error: 'invalidToken' } await expireOldCodes() // No hay escrow: comprobamos que el saldo cubre TODOS los códigos activos + este, // para no poder crear códigos que no se puedan respaldar. try { const [bal] = await db(DB.default).query( 'SELECT dp FROM api_points WHERE accountID = ?', [creatorAccount], ) const balance = bal[0] ? Number(bal[0].dp) : 0 const [act] = await db(DB.default).query( "SELECT COALESCE(SUM(points), 0) AS reserved FROM tradecode WHERE creator_account = ? AND status = 'active' AND expires_at > NOW()", [creatorAccount], ) const reserved = Number(act[0]?.reserved ?? 0) if (reserved + points > balance) return { success: false, error: 'insufficientPoints' } } catch { return { success: false, error: 'genericError' } } const code = generateCode() const expiresAt = new Date(Date.now() + CODE_TTL_MS) try { await db(DB.default).query( `INSERT INTO tradecode (code, creator_account, creator_character, points, gold, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, 'active', NOW(), ?)`, [code, creatorAccount, creatorCharacter, points, gold, expiresAt], ) } catch { return { success: false, error: 'genericError' } } return { success: true, code: { code, points, gold, expiresAt: expiresAt.toISOString() } } } /* ------------------------------- CHEQUEAR --------------------------------- */ export async function checkTradeCode(code: string): Promise<{ success: boolean; error?: string; points?: number; gold?: number; expiresAt?: string }> { const trimmed = code.trim() if (!trimmed) return { success: false, error: 'missingFields' } await expireOldCodes() try { const [rows] = await db(DB.default).query( "SELECT points, gold, status, expires_at FROM tradecode WHERE code = ? LIMIT 1", [trimmed], ) const row = rows[0] if (!row) return { success: false, error: 'codeNotFound' } if (row.status !== 'active' || new Date(row.expires_at).getTime() <= Date.now()) { return { success: false, error: 'codeUnavailable' } } return { success: true, points: Number(row.points), gold: Number(row.gold), expiresAt: new Date(row.expires_at).toISOString() } } catch { return { success: false, error: 'genericError' } } } /* -------------------------------- CANJEAR --------------------------------- */ export async function redeemTradeCode(params: { buyerAccount: number buyerEmail: string buyerCharacter: string code: string password: string token: string turnstileOk: boolean }): Promise<{ success: boolean; error?: string; points?: number; gold?: number }> { const { buyerAccount, buyerEmail, buyerCharacter, code, password, token, turnstileOk } = params const trimmedCode = code.trim() if (!trimmedCode || !buyerCharacter || !password || !token) return { success: false, error: 'missingFields' } if (!NAME_RE.test(buyerCharacter)) return { success: false, error: 'characterNotOwned' } if (!turnstileOk) return { success: false, error: 'captcha' } if (!(await authenticate(buyerEmail, password))) return { success: false, error: 'wrongPassword' } if (!(await checkSecurityToken(buyerAccount, token))) return { success: false, error: 'invalidToken' } // Bloqueo de 5 s: la cuenta no puede canjear si acaba de canjear otro código. try { const [recent] = await db(DB.default).query( `SELECT id FROM tradecode WHERE redeemer_account = ? AND redeemed_at > (NOW() - INTERVAL ? SECOND) LIMIT 1`, [buyerAccount, REDEEM_LOCK_SECONDS], ) if (recent[0]) return { success: false, error: 'locked' } } catch { /* ignore */ } await expireOldCodes() // Carga y valida el código. let creatorAccount = 0 let creatorCharacter = '' let points = 0 let gold = 0 try { const [rows] = await db(DB.default).query( "SELECT creator_account, creator_character, points, gold, status, expires_at FROM tradecode WHERE code = ? LIMIT 1", [trimmedCode], ) const row = rows[0] if (!row) return { success: false, error: 'codeNotFound' } if (row.status !== 'active' || new Date(row.expires_at).getTime() <= Date.now()) { return { success: false, error: 'codeUnavailable' } } creatorAccount = Number(row.creator_account) creatorCharacter = String(row.creator_character) points = Number(row.points) gold = Number(row.gold) } catch { return { success: false, error: 'genericError' } } if (creatorAccount === buyerAccount) return { success: false, error: 'cannotRedeemOwn' } if (!NAME_RE.test(creatorCharacter)) return { success: false, error: 'genericError' } // El personaje del comprador debe ser suyo, estar OFFLINE y tener oro suficiente. const buyerChar = await characterMoney(buyerAccount, buyerCharacter) if (!buyerChar) return { success: false, error: 'characterNotOwned' } if (buyerChar.online) return { success: false, error: 'characterOnline' } const costCopper = gold * GOLD_TO_COPPER if (buyerChar.money < costCopper) return { success: false, error: 'insufficientGold' } // 1) Reclama el código de forma atómica (evita doble canje concurrente). let claimed = false try { const [res] = await db(DB.default).query( `UPDATE tradecode SET status = 'redeemed', redeemer_account = ?, redeemer_character = ?, redeemed_at = NOW() WHERE code = ? AND status = 'active' AND expires_at > NOW()`, [buyerAccount, buyerCharacter, trimmedCode], ) claimed = res.affectedRows === 1 } catch { return { success: false, error: 'genericError' } } if (!claimed) return { success: false, error: 'codeUnavailable' } const revertClaim = async () => { try { await db(DB.default).query( "UPDATE tradecode SET status = 'active', redeemer_account = NULL, redeemer_character = NULL, redeemed_at = NULL WHERE code = ?", [trimmedCode], ) } catch { /* ignore */ } } // 2) Cobra el oro al personaje del comprador (condicional: offline y saldo). let goldTaken = false try { const [res] = await db(DB.characters).query( 'UPDATE characters SET money = money - ? WHERE name = ? AND account = ? AND online = 0 AND money >= ?', [costCopper, buyerCharacter, buyerAccount, costCopper], ) goldTaken = res.affectedRows === 1 } catch { goldTaken = false } if (!goldTaken) { await revertClaim() return { success: false, error: 'insufficientGold' } } const refundGold = async () => { try { await db(DB.characters).query('UPDATE characters SET money = money + ? WHERE name = ? AND account = ?', [ costCopper, buyerCharacter, buyerAccount, ]) } catch { /* ignore */ } } // 3) Mueve los PD del creador al comprador (transacción con bloqueo de fila). const pdMove = await transferDPoints(creatorAccount, buyerAccount, points) if (!pdMove.success) { await refundGold() await revertClaim() return { success: false, error: pdMove.error === 'insufficientFunds' ? 'creatorInsufficient' : 'genericError' } } // 4) Envía el oro por correo al personaje del creador (SOAP). Último paso. const subject = 'Comercio de PD' const body = `Has recibido ${gold} de oro por el canje de tu codigo ${trimmedCode}.` const soap = await executeSoapCommand(`.send money ${creatorCharacter} '${subject}' '${body}' ${costCopper}`) if (soap === null) { // Compensa: revierte PD y oro, y reabre el código. await transferDPoints(buyerAccount, creatorAccount, points) await refundGold() await revertClaim() return { success: false, error: 'deliveryFailed' } } return { success: true, points, gold } }