18fe2489d0
Ponía "Compra en la tienda (1 objeto)", que no identifica nada. Ahora: Tienda · BNet inna@inna.cl · Cuenta 15#1 · Innadin · Grito de la sirena (45327) x2 Cuenta Battle.net, cuenta de juego, personaje, y el nombre de cada ítem con su id. Es lo único que Stripe/SumUp enseñan en su panel, así que es lo que hay para identificar un pago cuando toca reclamar o devolver algo a mano. Se recorta a 200 caracteres porque las dos pasarelas limitan el campo (~250) y un carrito grande se pasaría. Se recortan solo los ÍTEMS ("+37 más"), nunca la cabecera: quién ha pagado importa más que el listado, que además queda entero en home_store_order. El recorte quita ítems hasta que quepa TODO con el sufijo incluido; comprobarlo antes de añadir el "+N más" se pasaba igual (206 chars con 12 ítems, visto en un checkout real). priceStoreCart trae ahora también el nombre del ítem, que no leía. Verificado contra checkouts reales de Stripe (test) con 1, 12 y 40 ítems: 82, 167 y 168 caracteres. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
import type { RowDataPacket } from 'mysql2'
|
||
import { db, DB } from './db'
|
||
import { executeSoapCommand } from './soap'
|
||
import { PD_PER_UNIT } from './dpoints'
|
||
import { VP_PRICE_FACTOR } from './pay-with-dpoints'
|
||
import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } from './store-pricing'
|
||
|
||
/**
|
||
* Tienda de ítems (transfiguración/equipo), portada del sistema antiguo (BENNU).
|
||
* Catálogo en `home_store_category` (árbol por `code` "1-1-1") + `home_store_item`
|
||
* (precio en PD o PV, cantidad, icono). Se paga con el saldo PD/PV de la cuenta y
|
||
* los ítems se envían por correo al personaje elegido (SOAP `.send items`).
|
||
*/
|
||
|
||
export type Currency = 'pd' | 'pv'
|
||
const SAFE_NAME = /^[A-Za-z]{1,12}$/
|
||
const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore
|
||
|
||
export interface StoreItem {
|
||
id: number // fila de home_store_item (clave del carrito)
|
||
itemId: number
|
||
name: string
|
||
icon: string | null
|
||
qty: number
|
||
currency: Currency
|
||
price: number
|
||
preview: string | null
|
||
}
|
||
|
||
/**
|
||
* Un trozo del nombre de una categoría: texto y, si lo lleva, la clase de color
|
||
* con la que hay que pintarlo (`warrior`, `second-brown`…).
|
||
*
|
||
* El nombre viene de BD como HTML (así lo hace el original: colorea los nombres
|
||
* de clase y pone las etiquetas en otro tono). Se trocea AQUÍ, en el servidor, y
|
||
* al cliente le llega ya estructurado: así se pinta con React y no hace falta
|
||
* meter HTML de la BD en el DOM.
|
||
*/
|
||
export interface NamePart {
|
||
text: string
|
||
cls?: string
|
||
}
|
||
|
||
export interface StoreCategory {
|
||
code: string
|
||
name: NamePart[]
|
||
/** Clase de la cabecera; a veces sustituye a first-brown para colorearla entera. */
|
||
css: string
|
||
depth: number
|
||
children: StoreCategory[]
|
||
items: StoreItem[]
|
||
}
|
||
|
||
interface CatRow extends RowDataPacket {
|
||
code: string
|
||
parent_code: string | null
|
||
name: string
|
||
css: string
|
||
depth: number
|
||
sort: number
|
||
}
|
||
interface ItemRow extends RowDataPacket {
|
||
id: number
|
||
category_code: string
|
||
item_id: number
|
||
name: string
|
||
icon: string | null
|
||
quantity: number
|
||
currency: Currency
|
||
price: number
|
||
preview: string | null
|
||
sort: number
|
||
}
|
||
|
||
/** `<span class="no-toggle X">…</span>` — la única etiqueta que usa el original. */
|
||
const NAME_SPAN = /<span class="no-toggle ([a-z0-9- ]+)">([^<]*)<\/span>/g
|
||
|
||
function unescapeHtml(s: string): string {
|
||
return s
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/&/g, '&')
|
||
}
|
||
|
||
/**
|
||
* Trocea el nombre HTML de una categoría en partes con su color.
|
||
*
|
||
* Solo entiende `<span class="no-toggle X">`, que es lo único que usa el
|
||
* catálogo. Cualquier otra etiqueta se ignora y queda su texto: si alguien mete
|
||
* HTML raro en la BD, aquí NO puede acabar inyectado (al cliente solo le llegan
|
||
* texto y un nombre de clase).
|
||
*/
|
||
export function parseCategoryName(html: string): NamePart[] {
|
||
const parts: NamePart[] = []
|
||
let last = 0
|
||
for (const m of html.matchAll(NAME_SPAN)) {
|
||
const plain = html.slice(last, m.index).replace(/<[^>]*>/g, '')
|
||
if (plain) parts.push({ text: unescapeHtml(plain) })
|
||
if (m[2]) parts.push({ text: unescapeHtml(m[2]), cls: m[1] })
|
||
last = m.index + m[0].length
|
||
}
|
||
const tail = html.slice(last).replace(/<[^>]*>/g, '')
|
||
if (tail) parts.push({ text: unescapeHtml(tail) })
|
||
return parts.length ? parts : [{ text: unescapeHtml(html.replace(/<[^>]*>/g, '')) }]
|
||
}
|
||
|
||
/**
|
||
* Árbol completo de la tienda (categorías anidadas con sus ítems en las hojas).
|
||
*
|
||
* El nombre del ítem sale de `home_item_data` (extraído de los DB2 del cliente
|
||
* 3.4.3, ver sql/db2/), que es el ÚNICO sitio con el nombre en inglés: el
|
||
* `home_store_item.name` del catálogo original es solo español. El de la
|
||
* categoría sale de `home_store_category.name_en` (ver sql/store_category_en.sql).
|
||
* En ambos casos, si falta el nombre en ese idioma se cae al del catálogo, que
|
||
* siempre está.
|
||
*/
|
||
export async function getStoreCatalog(locale: string): Promise<StoreCategory[]> {
|
||
// Solo se interpola el nombre de la COLUMNA, y sale de una lista cerrada:
|
||
// nunca del locale crudo (que viene de la URL).
|
||
const en = locale === 'en'
|
||
const nameCol = en ? 'name_en' : 'name_es'
|
||
const catNameCol = en ? 'name_en' : 'name'
|
||
let cats: CatRow[] = []
|
||
let items: ItemRow[] = []
|
||
try {
|
||
;[cats] = await db(DB.default).query<CatRow[]>(
|
||
`SELECT code, parent_code, COALESCE(NULLIF(${catNameCol}, ''), name) AS name, css, depth, sort
|
||
FROM home_store_category ORDER BY sort`,
|
||
)
|
||
;[items] = await db(DB.default).query<ItemRow[]>(
|
||
`SELECT i.id, i.category_code, i.item_id, COALESCE(NULLIF(d.${nameCol}, ''), i.name) AS name,
|
||
i.icon, i.quantity, i.currency, i.price, i.preview, i.sort
|
||
FROM home_store_item i
|
||
LEFT JOIN home_item_data d ON d.entry = i.item_id
|
||
ORDER BY i.sort`,
|
||
)
|
||
} catch {
|
||
return []
|
||
}
|
||
|
||
const nodes = new Map<string, StoreCategory>()
|
||
for (const c of cats) {
|
||
nodes.set(c.code, {
|
||
code: c.code,
|
||
name: parseCategoryName(c.name),
|
||
css: c.css || 'first-brown',
|
||
depth: c.depth,
|
||
children: [],
|
||
items: [],
|
||
})
|
||
}
|
||
const roots: StoreCategory[] = []
|
||
for (const c of cats) {
|
||
const node = nodes.get(c.code)!
|
||
if (c.parent_code && nodes.has(c.parent_code)) nodes.get(c.parent_code)!.children.push(node)
|
||
else roots.push(node)
|
||
}
|
||
for (const it of items) {
|
||
const leaf = nodes.get(it.category_code)
|
||
if (!leaf) continue
|
||
leaf.items.push({
|
||
id: it.id,
|
||
itemId: it.item_id,
|
||
name: it.name,
|
||
icon: it.icon,
|
||
qty: it.quantity,
|
||
currency: it.currency,
|
||
price: it.price,
|
||
preview: it.preview,
|
||
})
|
||
}
|
||
return roots
|
||
}
|
||
|
||
/** Saldos PD y PV de la cuenta (para mostrar y validar). */
|
||
export async function getStoreBalances(accountId: number): Promise<{ dp: number; vp: number }> {
|
||
if (!accountId) return { dp: 0, vp: 0 }
|
||
try {
|
||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||
'SELECT dp, vp FROM home_api_points WHERE accountID = ?',
|
||
[accountId],
|
||
)
|
||
return rows[0] ? { dp: Number(rows[0].dp), vp: Number(rows[0].vp) } : { dp: 0, vp: 0 }
|
||
} catch {
|
||
return { dp: 0, vp: 0 }
|
||
}
|
||
}
|
||
|
||
/** Línea que manda el cliente: fila del catálogo + cuántas copias quiere. */
|
||
export interface StoreCartLine {
|
||
id: number
|
||
copies: number
|
||
}
|
||
|
||
export interface PricedCart {
|
||
// `qty` = lote que entrega CADA copia (home_store_item.quantity);
|
||
// `copies` = cuántas veces se compra la línea. Total de unidades = qty*copies.
|
||
// `name` = nombre del catálogo; lo usa el concepto del cobro con tarjeta.
|
||
lines: { id: number; itemId: number; name: string; qty: number; currency: Currency; price: number; copies: number }[]
|
||
pdTotal: number
|
||
vpTotal: number
|
||
}
|
||
|
||
/**
|
||
* Concepto del cobro con tarjeta: quién compra y qué. Sale en la pasarela y en
|
||
* su panel, así que es lo único que tendrán Stripe/SumUp para identificar un
|
||
* pago si hay que reclamar o devolver algo a mano.
|
||
*
|
||
* Formato: `Tienda · BNet <email> · Cuenta <cuenta> · <personaje> · Nombre (id) x2, …`
|
||
*
|
||
* Se recorta a `MAX_CONCEPT`: Stripe y SumUp limitan el campo (~250-255), y un
|
||
* carrito de 100 líneas se pasaría de largo. Se recortan solo los ÍTEMS, nunca
|
||
* la cabecera: quién ha pagado importa más que el listado completo, que además
|
||
* queda entero en `home_store_order`.
|
||
*/
|
||
const MAX_CONCEPT = 200
|
||
|
||
export function storeConcept(
|
||
cart: PricedCart,
|
||
opts: { bnetEmail?: string; account?: string; character: string },
|
||
): string {
|
||
const head = [
|
||
'Tienda',
|
||
opts.bnetEmail ? `BNet ${opts.bnetEmail}` : null,
|
||
opts.account ? `Cuenta ${opts.account}` : null,
|
||
opts.character,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ')
|
||
const parts = cart.lines.map((l) => `${l.name} (${l.itemId})${l.copies > 1 ? ` x${l.copies}` : ''}`)
|
||
|
||
// Se van quitando ítems por el final hasta que quepa TODO, sufijo incluido: si
|
||
// se comprueba antes de añadir el "+N más" el resultado se pasa igualmente.
|
||
const fits = (s: string) => `${head} · ${s}`.length <= MAX_CONCEPT
|
||
let list = parts.join(', ')
|
||
for (let n = parts.length - 1; n >= 0 && !fits(list); n--) {
|
||
list = n > 0 ? `${parts.slice(0, n).join(', ')}, +${parts.length - n} más` : `${parts.length} ítems`
|
||
}
|
||
return fits(list) ? `${head} · ${list}` : head
|
||
}
|
||
|
||
/**
|
||
* Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del
|
||
* cliente) a partir de las filas `home_store_item.id` y las copias pedidas.
|
||
* Devuelve null si está vacío, si alguna copia no es válida o si algún id no
|
||
* existe en el catálogo (mismo criterio que `priceCart` de send-gift).
|
||
*/
|
||
export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart | null> {
|
||
const copiesById = new Map<number, number>()
|
||
for (const l of Array.isArray(cart) ? cart : []) {
|
||
const id = Math.floor(Number(l?.id))
|
||
const copies = Math.floor(Number(l?.copies))
|
||
if (!Number.isInteger(id) || id <= 0) return null
|
||
if (!Number.isInteger(copies) || copies < 1 || copies > MAX_COPIES) return null
|
||
copiesById.set(id, Math.min(MAX_COPIES, (copiesById.get(id) ?? 0) + copies))
|
||
}
|
||
if (copiesById.size === 0) return null
|
||
|
||
const ids = [...copiesById.keys()]
|
||
let rows: ItemRow[] = []
|
||
try {
|
||
;[rows] = await db(DB.default).query<ItemRow[]>(
|
||
`SELECT id, item_id, name, quantity, currency, price FROM home_store_item WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||
ids,
|
||
)
|
||
} catch {
|
||
return null
|
||
}
|
||
if (rows.length !== ids.length) return null // algún id no existe en el catálogo
|
||
|
||
const lines = rows.map((r) => ({
|
||
id: Number(r.id),
|
||
itemId: Number(r.item_id),
|
||
name: String(r.name),
|
||
qty: Number(r.quantity),
|
||
currency: r.currency,
|
||
price: Number(r.price),
|
||
copies: copiesById.get(Number(r.id))!,
|
||
}))
|
||
const sum = (c: Currency) =>
|
||
lines.filter((l) => l.currency === c).reduce((s, l) => s + l.price * l.copies, 0)
|
||
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
|
||
}
|
||
|
||
/** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */
|
||
interface CartEntry {
|
||
i: number
|
||
q: number
|
||
currency: Currency
|
||
price: number
|
||
}
|
||
|
||
/**
|
||
* Entradas para el SOAP/el pedido: cada copia va como una entrada propia
|
||
* (`2589:20 2589:20` = 2 lotes de 20), que es justo lo que ya sabe trocear
|
||
* `sendStoreItems` (12 por correo) y parsear `fulfillStoreOrder`. Cada entrada
|
||
* lleva su precio para poder reembolsar SOLO lo que no llegue a enviarse.
|
||
*/
|
||
function cartEntries(cart: PricedCart): CartEntry[] {
|
||
return cart.lines.flatMap((l) =>
|
||
Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty, currency: l.currency, price: l.price })),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Cobra el carrito (descuenta PD y PV de forma atómica, ambos a la vez) y envía
|
||
* los ítems por correo al personaje. Reembolsa si el envío por SOAP falla.
|
||
* Devuelve un código de error si algo no cuadra.
|
||
*/
|
||
export async function purchaseStoreCart(
|
||
accountId: number,
|
||
character: string,
|
||
cart: PricedCart,
|
||
): Promise<{ success: boolean; error?: string }> {
|
||
if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' }
|
||
if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' }
|
||
|
||
// Cobro atómico: bloquea la fila y exige saldo suficiente en AMBAS monedas.
|
||
const conn = await db(DB.default).getConnection()
|
||
let charged = false
|
||
try {
|
||
await conn.beginTransaction()
|
||
const [pts] = await conn.query<RowDataPacket[]>(
|
||
'SELECT dp, vp FROM home_api_points WHERE accountID = ? FOR UPDATE',
|
||
[accountId],
|
||
)
|
||
const dp = pts[0] ? Number(pts[0].dp) : 0
|
||
const vp = pts[0] ? Number(pts[0].vp) : 0
|
||
if (dp < cart.pdTotal) { await conn.rollback(); return { success: false, error: 'insufficientPd' } }
|
||
if (vp < cart.vpTotal) { await conn.rollback(); return { success: false, error: 'insufficientVp' } }
|
||
await conn.query('UPDATE home_api_points SET dp = dp - ?, vp = vp - ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId])
|
||
await conn.commit()
|
||
charged = true
|
||
} catch {
|
||
try { await conn.rollback() } catch { /* ignore */ }
|
||
return { success: false, error: 'genericError' }
|
||
} finally {
|
||
conn.release()
|
||
}
|
||
|
||
// Envío por correo (troceado a 12 ítems). Si falla a medias, se reembolsa SOLO
|
||
// lo que no se envió: los correos que ya salieron no se pueden recuperar, y
|
||
// devolver el carrito entero regalaría lo ya entregado.
|
||
const entries = cartEntries(cart)
|
||
const sent = await sendStoreItems(character, entries)
|
||
if (sent < entries.length) {
|
||
const left = entries.slice(sent)
|
||
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
|
||
const pdBack = back('pd')
|
||
const vpBack = back('pv')
|
||
if (charged && (pdBack > 0 || vpBack > 0)) {
|
||
await db(DB.default)
|
||
.query('UPDATE home_api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [pdBack, vpBack, accountId])
|
||
.catch(() => {})
|
||
}
|
||
// Si no salió ni un correo es el fallo de siempre (SOAP caído). Si salió
|
||
// alguno, el jugador tiene parte de la compra y solo se le devuelve el resto.
|
||
return { success: false, error: sent === 0 ? 'deliveryFailed' : 'partialDelivery' }
|
||
}
|
||
return { success: true }
|
||
}
|
||
|
||
/**
|
||
* Coste del carrito en euros para pagar con tarjeta. Inverso exacto de cómo se
|
||
* derivan los precios PD/PV de un precio en euros en el resto de servicios:
|
||
* 100 PD = 1 € y los PV cuestan VP_PRICE_FACTOR× (200 PV = 1 €).
|
||
*
|
||
* La implementación vive en `store-pricing` (módulo puro) para que el carrito
|
||
* del cliente enseñe EXACTAMENTE el importe que aquí se valida y se cobra. Este
|
||
* `if` es la red de seguridad de esa duplicación: si alguien cambia PD_PER_UNIT
|
||
* o VP_PRICE_FACTOR y no toca store-pricing, salta al primer uso en vez de
|
||
* cobrar mal en silencio.
|
||
*/
|
||
export function storeEuroTotal(pdTotal: number, vpTotal: number): number {
|
||
if (PD_PER_UNIT !== PD_PER_EUR || PD_PER_UNIT * VP_PRICE_FACTOR !== PV_PER_EUR) {
|
||
throw new Error(
|
||
`store-pricing desincronizado: PD_PER_UNIT=${PD_PER_UNIT}, VP_PRICE_FACTOR=${VP_PRICE_FACTOR}` +
|
||
` pero store-pricing tiene PD_PER_EUR=${PD_PER_EUR}, PV_PER_EUR=${PV_PER_EUR}`,
|
||
)
|
||
}
|
||
return pureEuroTotal(pdTotal, vpTotal)
|
||
}
|
||
|
||
/** Guarda el carrito como pedido pendiente (para entregarlo tras el pago con tarjeta). */
|
||
export async function createStoreOrder(
|
||
ref: string,
|
||
accountId: number,
|
||
character: string,
|
||
cart: PricedCart,
|
||
): Promise<boolean> {
|
||
// Formato `itemId:qty:precio:moneda` por entrada. El precio va guardado (y no
|
||
// se relee del catálogo al entregar) por dos razones: 114 item_id son
|
||
// ambiguos —el mismo objeto se vende a 50 PV y a 100 PD—, y aunque no lo
|
||
// fueran, hay que reembolsar lo que se COBRÓ, no lo que valga el ítem el día
|
||
// de la entrega. Se guarda en PD/PV, no en céntimos, para que el reembolso
|
||
// redondee igual que el cobro (storeEuroTotal, una sola vez sobre el total).
|
||
const items = cartEntries(cart).map((e) => `${e.i}:${e.q}:${e.price}:${e.currency}`).join(',')
|
||
const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
|
||
try {
|
||
await db(DB.default).query(
|
||
'INSERT INTO home_store_order (ref, account_id, character_name, items, amount) VALUES (?, ?, ?, ?, ?)',
|
||
[ref, accountId, character, items, amount],
|
||
)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Entrega un pedido pagado con tarjeta: busca el carrito por `ref` y envía los
|
||
* ítems por correo. Lo llama el fulfillment del servicio `store` (webhook/return).
|
||
*
|
||
* Aquí no se puede devolver saldo como en el pago con PD/PV: el dinero lo tiene
|
||
* la pasarela. Por eso devuelve cuánto hay que reembolsarle al cliente
|
||
* (`refundEur`), y quien llama —que es quien sabe si fue Stripe o SumUp— pide el
|
||
* reembolso por su API. No puede duplicar entregas porque `claimPaidCheckout` /
|
||
* `fulfillSumUpCheckout` reclaman el pago una sola vez.
|
||
*/
|
||
export async function fulfillStoreOrder(
|
||
character: string,
|
||
ref: string,
|
||
): Promise<{ ok: boolean; refundEur?: number }> {
|
||
if (!ref) return { ok: false }
|
||
let rows: RowDataPacket[] = []
|
||
try {
|
||
;[rows] = await db(DB.default).query<RowDataPacket[]>(
|
||
'SELECT character_name, items, amount FROM home_store_order WHERE ref = ?',
|
||
[ref],
|
||
)
|
||
} catch {
|
||
return { ok: false }
|
||
}
|
||
const order = rows[0]
|
||
if (!order) return { ok: false }
|
||
const target = character || String(order.character_name)
|
||
|
||
// `itemId:qty:precio:moneda`. Los pedidos anteriores a este formato solo traen
|
||
// `itemId:qty`: se entregan igual, pero de esos no se puede calcular cuánto
|
||
// reembolsar (no llevan precio), así que se avisa y se deja a mano.
|
||
const entries = String(order.items)
|
||
.split(',')
|
||
.map((p) => {
|
||
const [i, q, price, cur] = p.split(':')
|
||
return { i: Number(i), q: Number(q), price: Number(price), currency: cur as Currency }
|
||
})
|
||
.filter((e) => e.i > 0 && e.q >= 1)
|
||
if (entries.length === 0) return { ok: false }
|
||
const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv'))
|
||
|
||
const sent = await sendStoreItems(target, entries)
|
||
if (sent === entries.length) return { ok: true }
|
||
|
||
if (!priced) {
|
||
console.error(
|
||
`[store] pedido ${ref}: entregadas ${sent}/${entries.length} entradas, pero es de formato antiguo` +
|
||
` (sin precios) y no se puede calcular el reembolso. Revisar a mano.`,
|
||
)
|
||
return { ok: false }
|
||
}
|
||
const left = entries.slice(sent)
|
||
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
|
||
// Nunca más de lo cobrado: los dos importes se redondean por separado y podrían
|
||
// descuadrar un céntimo si se entregó justo la mitad de un ítem de PV impar.
|
||
const refundEur = Math.min(storeEuroTotal(back('pd'), back('pv')), Number(order.amount))
|
||
console.error(`[store] pedido ${ref}: entregadas ${sent}/${entries.length}; se reembolsan ${refundEur} €`)
|
||
return { ok: false, refundEur }
|
||
}
|
||
|
||
/**
|
||
* Envía los ítems de la tienda por correo (SOAP `.send items`), troceado a 12
|
||
* por correo.
|
||
*
|
||
* Devuelve CUÁNTAS entradas se entregaron, no un booleano: un correo enviado ya
|
||
* no se puede deshacer, así que si el 3.º de 9 falla, los 2 primeros están en el
|
||
* buzón del jugador y quien llame tiene que saberlo para reembolsar solo el
|
||
* resto. Cada correo es un `.send items`, así que el troceo es atómico por
|
||
* correo: un chunk sale entero o no sale.
|
||
*/
|
||
async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise<number> {
|
||
if (!SAFE_NAME.test(character)) return 0
|
||
// Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no
|
||
// enviar nada a enviar de menos y descuadrar el reembolso por índice.
|
||
if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0
|
||
const subject = 'Tienda'
|
||
const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
|
||
let sent = 0
|
||
for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) {
|
||
const chunk = items.slice(n, n + MAIL_ITEM_LIMIT)
|
||
const itemsStr = chunk.map((it) => `${Math.floor(it.i)}:${Math.floor(it.q)}`).join(' ')
|
||
const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`)
|
||
if (res === null) return sent
|
||
sent += chunk.length
|
||
}
|
||
return sent
|
||
}
|