web-next: change-race/faction/level-up/gold por SumUp + rastreador de misiones
- /change-race-character (3€), /change-faction-character (5€, valida condiciones: sin hermandad/correo/subastas/capitán-arena y oro <= cap por nivel), /level-up-character (10€), /gold-character (oro por correo SOAP). Cada uno borra su ruta antigua y actualiza el link del panel. - SumUp: hook precheck en PaidServiceConfig (condiciones cambio de facción); columna home_stripelog.metadata para campos extra (gold_amount). - /quest-character: rastreador de estado de misiones/cadenas (clase, Hijos de Hodir, misión por ID) en personaje offline; cooldowns por categoría, Turnstile, IDs de misión verificados en wotlkdb 3.3.5a. Tabla home_questsearchhistory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
/**
|
||||
* Condiciones para cambiar de facción (AzerothCore). El personaje NO puede:
|
||||
* tener subastas activas, correos en el buzón, ser capitán de un equipo de arena,
|
||||
* ser miembro de una hermandad, ni superar el máximo de oro según su nivel.
|
||||
*/
|
||||
|
||||
/** Máximo de oro (en oro) permitido según el nivel. */
|
||||
export function factionGoldCap(level: number): number {
|
||||
if (level <= 30) return 300
|
||||
if (level <= 50) return 1000
|
||||
if (level <= 70) return 5000
|
||||
return 20000
|
||||
}
|
||||
|
||||
export interface FactionEligibility {
|
||||
ok: boolean
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
export async function checkFactionChangeEligibility(accountId: number, character: string): Promise<FactionEligibility> {
|
||||
const [chars] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT guid, level, money FROM characters WHERE name = ? AND account = ? LIMIT 1',
|
||||
[character, accountId],
|
||||
)
|
||||
const ch = chars[0]
|
||||
if (!ch) return { ok: false, reasons: ['El personaje no pertenece a tu cuenta.'] }
|
||||
|
||||
const guid = Number(ch.guid)
|
||||
const level = Number(ch.level)
|
||||
const money = Number(ch.money)
|
||||
const reasons: string[] = []
|
||||
|
||||
const cap = factionGoldCap(level)
|
||||
if (money > cap * 10000) reasons.push(`tiene demasiado oro (máximo ${cap} para su nivel)`)
|
||||
|
||||
// Cada condición en su try/catch: alguna tabla puede no existir en esta BD.
|
||||
const exists = async (sql: string): Promise<boolean> => {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(sql, [guid])
|
||||
return Boolean(rows[0])
|
||||
} catch {
|
||||
return false // tabla ausente => no bloquea (el core lo valida al aplicar)
|
||||
}
|
||||
}
|
||||
|
||||
if (await exists('SELECT 1 FROM guild_member WHERE guid = ? LIMIT 1')) reasons.push('es miembro de una hermandad')
|
||||
if (await exists('SELECT 1 FROM mail WHERE receiver = ? LIMIT 1')) reasons.push('tiene correos en el buzón')
|
||||
if (await exists('SELECT 1 FROM auctionhouse WHERE owner = ? LIMIT 1')) reasons.push('tiene subastas activas')
|
||||
if (await exists('SELECT 1 FROM arena_team WHERE captainGuid = ? LIMIT 1')) reasons.push('es capitán de un equipo de arena')
|
||||
|
||||
return { ok: reasons.length === 0, reasons }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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>
|
||||
}
|
||||
|
||||
@@ -12,10 +13,10 @@ export interface GameCharacter {
|
||||
export async function getGameCharacters(accountId: number): Promise<GameCharacter[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT name, online, class FROM characters WHERE account = ?',
|
||||
'SELECT name, online, class, level FROM characters WHERE account = ?',
|
||||
[accountId],
|
||||
)
|
||||
return rows.map((r) => ({ name: r.name, online: r.online === 1, classCss: getClassCss(r.class) }))
|
||||
return rows.map((r) => ({ name: r.name, online: r.online === 1, level: Number(r.level), classCss: getClassCss(r.class) }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { executeSoapCommand } from './soap'
|
||||
import { db, DB } from './db'
|
||||
import { creditDPoints } from './dpoints'
|
||||
import { checkFactionChangeEligibility } from './change-faction'
|
||||
import {
|
||||
getRenamePrice,
|
||||
getCustomizePrice,
|
||||
@@ -18,22 +18,23 @@ export interface PaidServiceConfig {
|
||||
productName: (character: string, meta: Meta) => string
|
||||
fulfill: (character: string, meta: Meta) => Promise<boolean>
|
||||
extraFields: string[] // campos (además de character) que el form envía y se guardan en metadata
|
||||
// Comprobación previa al pago (p.ej. condiciones de cambio de facción). Si `ok`
|
||||
// es false, se bloquea el checkout y se muestra `message`.
|
||||
precheck?: (accountId: number, character: string) => Promise<{ ok: boolean; message?: string }>
|
||||
}
|
||||
|
||||
async function soapFulfill(command: string): Promise<boolean> {
|
||||
return (await executeSoapCommand(command)) !== null
|
||||
}
|
||||
|
||||
async function addGold(character: string, goldAmount: number): Promise<boolean> {
|
||||
try {
|
||||
await db(DB.characters).query('UPDATE characters SET money = money + ? WHERE name = ?', [
|
||||
goldAmount * 10000,
|
||||
character,
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
/** Nombre de personaje válido (evita inyección en el comando SOAP). */
|
||||
const SAFE_NAME = /^[A-Za-z]{1,12}$/
|
||||
|
||||
/** Envía el oro por correo al personaje (como el diseño). Requiere el worldserver. */
|
||||
async function sendGoldByMail(character: string, goldAmount: number): Promise<boolean> {
|
||||
if (!SAFE_NAME.test(character) || !(goldAmount > 0)) return false
|
||||
const copper = goldAmount * 10000
|
||||
return soapFulfill(`.send money "${character}" "Adquirir oro" "Has recibido ${goldAmount} de oro." ${copper}`)
|
||||
}
|
||||
|
||||
export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
@@ -59,6 +60,10 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
price: () => getChangeFactionPrice(),
|
||||
productName: (c) => `Cambiar facción: ${c}`,
|
||||
fulfill: (c) => soapFulfill(`.char changef ${c}`),
|
||||
precheck: async (accountId, character) => {
|
||||
const r = await checkFactionChangeEligibility(accountId, character)
|
||||
return { ok: r.ok, message: r.ok ? undefined : `No se puede cambiar la facción: el personaje ${r.reasons.join('; ')}.` }
|
||||
},
|
||||
extraFields: [],
|
||||
},
|
||||
'level-up': {
|
||||
@@ -69,8 +74,8 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
},
|
||||
gold: {
|
||||
price: (m) => goldPriceFor(Number(m.gold_amount)),
|
||||
productName: (c, m) => `Aumentar el Oro: ${m.gold_amount} al Personaje: ${c}`,
|
||||
fulfill: (c, m) => addGold(c, Number(m.gold_amount)),
|
||||
productName: (c, m) => `Adquirir oro: ${m.gold_amount} al personaje ${c}`,
|
||||
fulfill: (c, m) => sendGoldByMail(c, Number(m.gold_amount)),
|
||||
extraFields: ['gold_amount'],
|
||||
},
|
||||
transfer: {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
/**
|
||||
* Rastreador de misiones (/quest-character): comprueba el estado (completada /
|
||||
* en curso / sin empezar) de misiones o cadenas de misiones en un personaje.
|
||||
*
|
||||
* Categorías (cooldown por personaje):
|
||||
* - 'class' cadenas de misiones de clase — cada 30 min
|
||||
* - 'reputation' cadenas de misiones (reputación) — cada 1 h
|
||||
* - 'specific' una misión concreta por ID — cada 10 min
|
||||
*/
|
||||
|
||||
export type QuestCategory = 'class' | 'reputation' | 'specific'
|
||||
|
||||
export const COOLDOWN_MS: Record<QuestCategory, number> = {
|
||||
reputation: 60 * 60 * 1000,
|
||||
class: 30 * 60 * 1000,
|
||||
specific: 10 * 60 * 1000,
|
||||
}
|
||||
|
||||
export interface QuestOption {
|
||||
key: string
|
||||
category: Exclude<QuestCategory, 'specific'>
|
||||
classCss: string | null // clase a la que aplica ('warlock'…) o null (cualquier clase)
|
||||
label: string
|
||||
link?: { type: 'spell' | 'item' | 'faction'; id: number }
|
||||
icon?: string
|
||||
minLevel: number
|
||||
quests: number[] // IDs de misión de la cadena (variantes de facción combinadas)
|
||||
}
|
||||
|
||||
// IDs de misión verificados en la BD WotLK 3.3.5a (wotlkdb.com): la misión FINAL
|
||||
// que otorga cada habilidad, combinando variantes de facción/raza (el personaje
|
||||
// solo tendrá en su registro las de su facción/raza). Editables aquí si hiciera falta.
|
||||
export const QUEST_OPTIONS: QuestOption[] = [
|
||||
// Brujo
|
||||
{ key: 'wl_voidwalker', category: 'class', classCss: 'warlock', label: 'Invocar abisario', link: { type: 'spell', id: 697 }, minLevel: 10, icon: 'spell_shadow_summonvoidwalker', quests: [1689, 1504, 1471] },
|
||||
{ key: 'wl_succubus', category: 'class', classCss: 'warlock', label: 'Invocar súcubo', link: { type: 'spell', id: 712 }, minLevel: 20, icon: 'spell_shadow_summonsuccubus', quests: [1739, 1513, 1474] },
|
||||
{ key: 'wl_felhunter', category: 'class', classCss: 'warlock', label: 'Invocar manáfago', link: { type: 'spell', id: 691 }, minLevel: 30, icon: 'spell_shadow_summonfelhunter', quests: [1795] },
|
||||
{ key: 'wl_inferno', category: 'class', classCss: 'warlock', label: 'Inferno', link: { type: 'spell', id: 1122 }, minLevel: 50, icon: 'spell_shadow_summoninfernal', quests: [7603] },
|
||||
{ key: 'wl_doom', category: 'class', classCss: 'warlock', label: 'Ritual de fatalidad', link: { type: 'spell', id: 18540 }, minLevel: 60, icon: 'spell_shadow_antimagicshell', quests: [7583] },
|
||||
// Cazador
|
||||
{ key: 'hunter_pet', category: 'class', classCss: 'hunter', label: 'Habilidades de mascota', minLevel: 10, quests: [6103, 6086, 9675, 6081, 6089, 9673] },
|
||||
// Chamán
|
||||
{ key: 'sh_earth', category: 'class', classCss: 'shaman', label: 'Tótem de tierra', link: { type: 'item', id: 5175 }, minLevel: 4, icon: 'spell_totem_wardofdraining', quests: [9451, 1518, 1521] },
|
||||
{ key: 'sh_fire', category: 'class', classCss: 'shaman', label: 'Tótem de fuego', link: { type: 'item', id: 5176 }, minLevel: 10, icon: 'spell_totem_wardofdraining', quests: [9555, 1527] },
|
||||
{ key: 'sh_water', category: 'class', classCss: 'shaman', label: 'Tótem de agua', link: { type: 'item', id: 5177 }, minLevel: 20, icon: 'spell_totem_wardofdraining', quests: [9509, 96] },
|
||||
{ key: 'sh_air', category: 'class', classCss: 'shaman', label: 'Tótem de aire', link: { type: 'item', id: 5178 }, minLevel: 30, icon: 'spell_totem_wardofdraining', quests: [9554, 1531, 1532] },
|
||||
// Druida
|
||||
{ key: 'dr_bear', category: 'class', classCss: 'druid', label: 'Forma de oso', link: { type: 'spell', id: 5487 }, minLevel: 10, icon: 'ability_racial_bearform', quests: [6001, 6002] },
|
||||
{ key: 'dr_curepoison', category: 'class', classCss: 'druid', label: 'Curar envenenamiento', link: { type: 'spell', id: 8946 }, minLevel: 14, icon: 'spell_nature_nullifypoison', quests: [6125, 6130] },
|
||||
{ key: 'dr_aquatic', category: 'class', classCss: 'druid', label: 'Forma acuática', link: { type: 'spell', id: 1066 }, minLevel: 16, icon: 'ability_druid_aquaticform', quests: [5061, 31] },
|
||||
{ key: 'dr_flight', category: 'class', classCss: 'druid', label: 'Forma de vuelo presto', link: { type: 'spell', id: 40120 }, minLevel: 70, icon: 'ability_druid_flightform', quests: [11001] },
|
||||
// Guerrero
|
||||
{ key: 'wr_defensive', category: 'class', classCss: 'warrior', label: 'Actitud defensiva', link: { type: 'spell', id: 71 }, minLevel: 10, icon: 'ability_warrior_defensivestance', quests: [1665, 1678, 1683, 9582, 10350, 1498, 1819] },
|
||||
{ key: 'wr_berserker', category: 'class', classCss: 'warrior', label: 'Actitud rabiosa', link: { type: 'spell', id: 2458 }, minLevel: 30, icon: 'ability_racial_avatar', quests: [1719] },
|
||||
// Paladín
|
||||
{ key: 'pal_redemption', category: 'class', classCss: 'paladin', label: 'Redención', link: { type: 'spell', id: 7328 }, minLevel: 12, icon: 'spell_holy_resurrection', quests: [1788, 9598, 9685] },
|
||||
// Reputación
|
||||
{ key: 'rep_hodir', category: 'reputation', classCss: null, label: 'Los Hijos de Hodir', link: { type: 'faction', id: 1119 }, minLevel: 77, quests: [12843, 13064, 12915, 12956] },
|
||||
]
|
||||
|
||||
/** Opciones que un personaje (clase + nivel) puede rastrear. */
|
||||
export function optionsFor(classCss: string, level: number): QuestOption[] {
|
||||
return QUEST_OPTIONS.filter(
|
||||
(o) => (o.classCss === null || o.classCss === classCss) && level >= o.minLevel && o.quests.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export type QuestState = 'rewarded' | 'inProgress' | 'none'
|
||||
export interface QuestStatus {
|
||||
quest: number
|
||||
state: QuestState
|
||||
}
|
||||
|
||||
/** Estado de cada misión (completada/en curso/sin empezar) para un personaje. */
|
||||
export async function questStatuses(guid: number, quests: number[]): Promise<QuestStatus[]> {
|
||||
if (quests.length === 0) return []
|
||||
const ph = quests.map(() => '?').join(',')
|
||||
const rewarded = new Set<number>()
|
||||
const inProgress = new Set<number>()
|
||||
try {
|
||||
const [r] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT quest FROM character_queststatus_rewarded WHERE guid = ? AND quest IN (${ph})`,
|
||||
[guid, ...quests],
|
||||
)
|
||||
for (const row of r) rewarded.add(Number(row.quest))
|
||||
const [p] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT quest FROM character_queststatus WHERE guid = ? AND quest IN (${ph})`,
|
||||
[guid, ...quests],
|
||||
)
|
||||
for (const row of p) inProgress.add(Number(row.quest))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return quests.map((q) => ({
|
||||
quest: q,
|
||||
state: rewarded.has(q) ? 'rewarded' : inProgress.has(q) ? 'inProgress' : 'none',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Minutos restantes de cooldown para (personaje, categoría), o 0 si disponible. */
|
||||
export async function cooldownRemainingMs(character: string, category: QuestCategory): Promise<number> {
|
||||
try {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT searched_at FROM home_questsearchhistory WHERE character_name = ? AND category = ? ORDER BY searched_at DESC LIMIT 1',
|
||||
[character, category],
|
||||
)
|
||||
if (!rows[0]) return 0
|
||||
const diff = Date.now() - new Date(rows[0].searched_at).getTime()
|
||||
return diff < COOLDOWN_MS[category] ? COOLDOWN_MS[category] - diff : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordSearch(character: string, category: QuestCategory): Promise<void> {
|
||||
try {
|
||||
await db(DB.default).query(
|
||||
'INSERT INTO home_questsearchhistory (character_name, category, searched_at) VALUES (?, ?, NOW())',
|
||||
[character, category],
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
+26
-6
@@ -31,6 +31,8 @@ interface CreateParams {
|
||||
// servicio sobre `characterName` en vez de acreditar PD. Sin `service` = compra de PD.
|
||||
service?: string
|
||||
characterName?: string
|
||||
// Campos extra del servicio (p.ej. { gold_amount }) que la entrega necesita.
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,9 +62,21 @@ export async function createSumUpCheckout(p: CreateParams): Promise<{ success: b
|
||||
if (!url) return { success: false, error: 'noHostedUrl' }
|
||||
|
||||
await db(DB.default).query(
|
||||
'INSERT INTO home_stripelog (account_id, username, email, acore_ip, stripe_ip, product_name, amount, session_id, mode, character_name, service, timestamp, fulfilled) ' +
|
||||
'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, NOW(), 0)',
|
||||
[p.accountId, p.username, p.email, p.acoreIp, p.description, p.amount, p.reference, 'sumup', p.characterName ?? p.username, p.service ?? null],
|
||||
'INSERT INTO home_stripelog (account_id, username, email, acore_ip, stripe_ip, product_name, amount, session_id, mode, character_name, service, metadata, timestamp, fulfilled) ' +
|
||||
'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, NOW(), 0)',
|
||||
[
|
||||
p.accountId,
|
||||
p.username,
|
||||
p.email,
|
||||
p.acoreIp,
|
||||
p.description,
|
||||
p.amount,
|
||||
p.reference,
|
||||
'sumup',
|
||||
p.characterName ?? p.username,
|
||||
p.service ?? null,
|
||||
p.metadata ? JSON.stringify(p.metadata) : null,
|
||||
],
|
||||
)
|
||||
return { success: true, url }
|
||||
} catch (e) {
|
||||
@@ -96,7 +110,7 @@ export async function fulfillSumUpCheckout(
|
||||
if (!paid) return { ok: false, paid: false }
|
||||
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT id, account_id, amount, service, character_name FROM home_stripelog WHERE session_id = ? AND mode = ?',
|
||||
'SELECT id, account_id, amount, service, character_name, metadata FROM home_stripelog WHERE session_id = ? AND mode = ?',
|
||||
[reference, 'sumup'],
|
||||
)
|
||||
const log = rows[0]
|
||||
@@ -109,11 +123,17 @@ export async function fulfillSumUpCheckout(
|
||||
)
|
||||
if (res.affectedRows === 0) return { ok: false, paid: true }
|
||||
|
||||
// Pago de servicio (renombrar…): ejecuta la acción sobre el personaje.
|
||||
// Pago de servicio (renombrar, oro…): ejecuta la acción sobre el personaje.
|
||||
const service = log.service ? String(log.service) : ''
|
||||
const cfg = service ? getPaidService(service) : null
|
||||
if (cfg) {
|
||||
const ok = await cfg.fulfill(String(log.character_name), { service })
|
||||
let meta: Record<string, string> = { service }
|
||||
try {
|
||||
if (log.metadata) meta = { ...JSON.parse(String(log.metadata)), service }
|
||||
} catch {
|
||||
/* metadata corrupto: seguimos con lo básico */
|
||||
}
|
||||
const ok = await cfg.fulfill(String(log.character_name), meta)
|
||||
return { ok, paid: true, service, character: String(log.character_name) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user