Files
NightSpire/web-next/lib/quest-tracker.ts
T
Inna cccc70338d Quitar el prefijo home_ de las 41 tablas del portal
El prefijo venía del nombre de la app Django (`home`), jubilada y borrada hoy,
así que ya no significaba nada: home_stripelog -> stripelog. La BD se sigue
llamando django_wow por historia (renombrarla es otra operación).

Comprobado antes de tocar nada: 0 colisiones con nombres existentes, 0 palabras
reservadas (contrastado contra las 260 de MySQL), sin vistas ni triggers que
dependieran de ellas. El RENAME va en una sola sentencia porque así es atómico,
y MySQL reapunta solo las 4 claves ajenas entre estas tablas.

170 referencias actualizadas en 37 ficheros. Se dejó a propósito
sql/drop_home_securitytoken_authuser_fk.sql sin tocar: es una migración ya
aplicada y reescribirla sería falsear el historial.

De paso salió un fallo previo: prices.ts consultaba `home_restoreitemprice`,
una tabla que NO existe. El try/catch de priceFrom se comía el error y devolvía
siempre el precio por defecto, así que el precio de restaurar objetos nunca fue
configurable. Se deja documentado en el código; crear la tabla es otra decisión.

Verificado tras aplicar: 0 tablas home_, las 51 siguen ahí, y el conteo exacto
de filas cuadra con el volcado previo (store_item 3068, item_data 44873,
stripelog 35, store_order 26, noticia 50). La web responde 200 en todas las
rutas y la portada carga las noticias sin errores.

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

128 lines
7.2 KiB
TypeScript

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 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 questsearchhistory (character_name, category, searched_at) VALUES (?, ?, NOW())',
[character, category],
)
} catch {
/* ignore */
}
}