/** * MapleCore Character Image Discord Bot * Standalone bot - no full webserver needed. * * Usage: !char * /char name: * * Dependencies: * npm install discord.js mysql2 canvas axios dotenv */ require("dotenv").config(); const { Client, GatewayIntentBits, SlashCommandBuilder, REST, Routes, AttachmentBuilder, EmbedBuilder } = require("discord.js"); const mysql = require("mysql2/promise"); const { createCanvas, loadImage } = require("canvas"); const axios = require("axios"); // ─── Config ────────────────────────────────────────────────────────────────── const CONFIG = { token: process.env.DISCORD_TOKEN, clientId: process.env.DISCORD_CLIENT_ID, guildId: process.env.DISCORD_GUILD_ID, prefix: process.env.BOT_PREFIX || "!", rankingsChannelId: process.env.RANKINGS_CHANNEL_ID, db: { host: process.env.DB_HOST || "localhost", port: parseInt(process.env.DB_PORT) || 3306, user: process.env.DB_USER || "root", password: process.env.DB_PASSWORD || "root", database: process.env.DB_NAME || "cosmic", }, }; // ─── Equipment slot IDs (Cosmic / HeavenMS v83 standard) ───────────────────── // inventoryitems.position values for equipped items (inventorytype = 1) const EQUIP_SLOTS = { 1: "Hat", 2: "Face", 3: "Eye Accessory", 4: "Earring", 5: "Top", 6: "Bottom", 7: "Shoes", 8: "Glove", 9: "Cape", 10: "Shield", 11: "Weapon", 12: "Ring 1", 13: "Ring 2", 15: "Ring 3", 16: "Ring 4", 17: "Pendant", 49: "Belt", 50: "Medal", }; // maplestory.io render layers (order matters for compositing) const RENDER_LAYERS = ["body", "arm", "head", "face", "hair"]; // ─── Database ───────────────────────────────────────────────────────────────── let pool; async function getDb() { if (!pool) { pool = mysql.createPool({ ...CONFIG.db, waitForConnections: true, connectionLimit: 5, }); } return pool; } /** * Fetch character row + equipped item IDs from the Cosmic DB. * Returns null if character not found. */ async function fetchCharacterData(characterName) { const db = await getDb(); // Get character base info const [chars] = await db.query( `SELECT id, name, level, job, gender, hair, face, skincolor, exp, meso FROM characters WHERE name = ? LIMIT 1`, [characterName] ); if (chars.length === 0) return null; const char = chars[0]; // Get equipped items (inventorytype=1, position < 0 means equipped in some servers; // position > 0 and inventorytype=1 in others — handle both) // inventorytype = -1 is the equipped inventory in Cosmic/HeavenMS // Regular equipped slots: -1 to -11 // Cash equipped slots: -101 to -111 (override regular) const [items] = await db.query( `SELECT itemid, position FROM inventoryitems WHERE characterid = ? AND inventorytype = -1 ORDER BY position DESC`, [char.id] ); // Map negative position values to named slot keys (from db.ts mapEquipmentPosition) // Regular slots: -1 to -11 | Cash slots: -101 to -111 (cash overrides regular) const SLOT_MAP = { '-1': 'cap', '-101': 'cap', '-2': 'mask', '-102': 'mask', '-3': 'eyes', '-103': 'eyes', '-4': 'ears', '-104': 'ears', '-5': 'coat', '-105': 'coat', '-6': 'pants', '-106': 'pants', '-7': 'shoes', '-107': 'shoes', '-8': 'glove', '-108': 'glove', '-9': 'cape', '-109': 'cape', '-10': 'shield','-110': 'shield', '-11': 'weapon','-111': 'weapon', }; // First pass: regular items; second pass: cash items override const equipped = {}; for (const item of items) { const key = SLOT_MAP[String(item.position)]; if (key) { // Only override if slot is empty OR this is a cash item (-101 and below) if (!equipped[key] || item.position <= -101) { equipped[key] = item.itemid; } } } return { ...char, equipped }; } // ─── Image Generation ───────────────────────────────────────────────────────── /** * Build a maplestory.io character render URL. * maplestory.io supports compositing equipped items into a single PNG. * * Docs: https://maplestory.io/api */ function buildRenderUrl(charData) { const { hair, face, skincolor, equipped } = charData; const VERSION = '241'; // Skin color mapping: DB value -> maplestory.io skin item ID const SKIN_MAP = { 0: 2000, 1: 2001, 2: 2002, 3: 2003 }; const skinId = SKIN_MAP[skincolor] ?? 2000; // Determine stance based on weapon type (2H weapons use stand2) const weaponId = equipped.weapon; let stance = 'stand1'; if (weaponId) { const twoHanded = [ [1402000,1402999],[1412000,1412999],[1422000,1422999],[1432000,1432999], [1452000,1459999],[1462000,1469999],[1382000,1389999],[1372000,1379999], [1442000,1442999],[1700000,1799999], ]; if (twoHanded.some(([min,max]) => weaponId >= min && weaponId <= max)) { stance = 'stand2'; } } // Build items in same order as maplestory-api.ts: // hair, weapon, shoes, pants, coat, glove, cape, shield, cap, mask, eyes, ears, skin, 12000 (head), face const itemList = []; const push = (id) => { if (id) itemList.push({ itemId: id, version: VERSION }); }; push(hair || 30021); push(equipped.weapon); push(equipped.shoes); push(equipped.pants); push(equipped.coat); push(equipped.glove); push(equipped.cape); push(equipped.shield); push(equipped.cap); push(equipped.mask); push(equipped.eyes); push(equipped.ears); push(skinId); push(12000); // head base push(face || 20001); // Format: comma-separated JSON objects (NOT a JSON array), URL-encoded const itemsParam = encodeURIComponent(itemList.map(i => JSON.stringify(i)).join(',')); return `https://maplestory.io/api/character/${itemsParam}/${stance}/0?resize=2&renderMode=default&flipX=false`; } /** * Generate a character preview image Buffer using maplestory.io. * Falls back to a styled embed if the API is unreachable. */ async function generateCharacterImage(charData) { const url = buildRenderUrl(charData); console.log("Render URL:", decodeURIComponent(url)); try { const resp = await axios.get(url, { responseType: "arraybuffer", timeout: 10000, headers: { "User-Agent": "MapleCore-DiscordBot/1.0" }, }); return Buffer.from(resp.data); } catch (err) { console.error("maplestory.io render failed:", err.message); // Fallback: generate a simple placeholder with canvas return generateFallbackImage(charData); } } /** Simple canvas fallback when maplestory.io is unavailable */ async function generateFallbackImage(charData) { const canvas = createCanvas(200, 300); const ctx = canvas.getContext("2d"); // Background ctx.fillStyle = "#1a1a2e"; ctx.fillRect(0, 0, 200, 300); // Border ctx.strokeStyle = "#e94560"; ctx.lineWidth = 3; ctx.strokeRect(4, 4, 192, 292); // Character name ctx.fillStyle = "#ffffff"; ctx.font = "bold 18px Arial"; ctx.textAlign = "center"; ctx.fillText(charData.name, 100, 40); // Job / level ctx.fillStyle = "#a0a0c0"; ctx.font = "14px Arial"; ctx.fillText(`Lv.${charData.level}`, 100, 65); // Silhouette placeholder ctx.fillStyle = "#e94560"; ctx.font = "60px Arial"; ctx.textAlign = "center"; ctx.fillText("🍁", 100, 180); ctx.fillStyle = "#606080"; ctx.font = "11px Arial"; ctx.fillText("(preview unavailable)", 100, 270); return canvas.toBuffer("image/png"); } // ─── Embed Builder ──────────────────────────────────────────────────────────── function getJobName(jobId) { const jobs = { 0: "Beginner", 100: "Warrior", 110: "Fighter", 111: "Crusader", 112: "Hero", 120: "Page", 121: "White Knight", 122: "Paladin", 130: "Spearman", 131: "Dragon Knight", 132: "Dark Knight", 200: "Magician", 210: "F/P Wizard", 211: "F/P Mage", 212: "F/P Arch Mage", 220: "I/L Wizard", 221: "I/L Mage", 222: "I/L Arch Mage", 230: "Cleric", 231: "Priest", 232: "Bishop", 300: "Bowman", 310: "Hunter", 311: "Ranger", 312: "Bowmaster", 320: "Crossbowman", 321: "Sniper", 322: "Marksman", 400: "Thief", 410: "Assassin", 411: "Hermit", 412: "Night Lord", 420: "Bandit", 421: "Chief Bandit", 422: "Shadower", 500: "Pirate", 510: "Brawler", 511: "Marauder", 512: "Buccaneer", 520: "Gunslinger", 521: "Outlaw", 522: "Corsair", 900: "GM", 910: "Super GM", }; return jobs[jobId] ?? `Job ${jobId}`; } // Real pre-Big Bang v83 EXP required to level up FROM each level // Source: MapleLegends EXP table (https://legends.ml/lib/exptable) const EXP_TABLE = [ 15,15,34,57,92,135,372,560,840,1144, 1242,1573,2144,2800,3640,4700,5893,7360,9144,11120, 13477,16268,19320,22880,27008,31477,36600,42444,48720,55813, 63800,86784,98208,110932,124432,139372,155865,173280,192400,213345, 235372,259392,285532,312928,342624,374760,408336,445544,483532,524160, 567772,598886,631704,666321,702836,741351,781976,824828,870028,917625, 967995,1021041,1076994,1136013,1198266,1263930,1333194,1406252,1483314,1564600, 1650340,1740778,1836173,1936794,2042930,2154882,2272970,2397528,2528912,2667496, 2813674,2967863,3130502,3302053,3483005,3673873,3875201,4087562,4311559,4547832, 4797053,5059931,5337215,5629694,5938202,6263614,6606860,6968915,7350811,7753635, 8178534,8626718,9099462,9598112,10124088,10678888,11264090,11881362,12532461,13219239, 13943653,14707765,15513750,16363902,17260644,18206527,19204245,20256637,21366700,22537594, 23772654,25075395,26449526,27898960,29427822,31040466,32741483,34535716,36428273,38424542, 40530206,42751262,45094030,47565183,50171755,52921167,55821246,58880250,62106888,65510344, 69100311,72887008,76881216,81094306,85594273,90225770,95170142,100385466,105886589,111689174, 117809740,124265714,131075474,138258410,145834970,153826726,162256430,171148082,180526997,190419876, 200854885,211861732,223471711,223471711,248635353,262260570,276632449,291791906,307782102,324648562, 342439302,361204976,380999008,401877754,423900654,447130410,471633156,497478653,524740482,553496261, 583827855,615821622,649568646,685165008,722712050,762316670,804091623,848155844,894634784,943660770, 995373379,1049919840,1107455447,1168144006,1232158297,1299680571,1370903066,1446028554,1525246918,1608855764, 1697021059 ]; function getExpForLevel(level) { // Returns EXP required to go FROM this level TO the next if (level < 1 || level > 200) return 0; return EXP_TABLE[level]; } // Render a text-based EXP progress bar function buildExpBar(pct, width = 20) { const filled = Math.round((pct / 100) * width); const empty = width - filled; return `${"█".repeat(filled)}${"░".repeat(empty)}`; } function buildEmbed(charData, imageFile) { // Calculate EXP progress // charData.exp is EXP accumulated within the current level (resets on levelup in Cosmic) const expCurrent = Number(charData.exp) || 0; const expNeeded = getExpForLevel(charData.level); // EXP required to level up from current level const pct = expNeeded > 0 ? Math.min(100, Math.floor((expCurrent / expNeeded) * 100)) : 100; const expBar = buildExpBar(pct); const expField = [ `\`${expBar}\` **${pct}%**`, `${expCurrent.toLocaleString()} / ${expNeeded.toLocaleString()} EXP`, ].join("\n"); const embed = new EmbedBuilder() .setTitle(charData.name) .setColor(0xe94560) .addFields( { name: "Level", value: `${charData.level}`, inline: true }, { name: "Job", value: getJobName(charData.job), inline: true }, { name: "Meso", value: charData.meso?.toLocaleString() ?? "0", inline: true }, { name: "EXP to Next Level", value: expField }, ) .setImage(`attachment://${imageFile}`) .setFooter({ text: "Sweetbot" }) .setTimestamp(); return embed; } // ─── Command Handler ────────────────────────────────────────────────────────── async function handleCharCommand(interaction, characterName) { // Works for both slash commands (interaction = CommandInteraction) // and prefix messages (interaction has .reply()) const isSlash = !!interaction.deferReply; if (isSlash) await interaction.deferReply(); const name = characterName?.trim(); if (!name) { const msg = "Please provide a character name. Usage: `/char name:YourCharacter`"; return isSlash ? interaction.editReply(msg) : interaction.reply(msg); } try { const charData = await fetchCharacterData(name); if (!charData) { const msg = `❌ Character **${name}** not found.`; return isSlash ? interaction.editReply(msg) : interaction.reply(msg); } const imageBuffer = await generateCharacterImage(charData); const fileName = `${charData.name}.png`; const attachment = new AttachmentBuilder(imageBuffer, { name: fileName }); const embed = buildEmbed(charData, fileName); const payload = { embeds: [embed], files: [attachment] }; return isSlash ? interaction.editReply(payload) : interaction.reply(payload); } catch (err) { console.error("Error handling char command:", err); const msg = "⚠️ An error occurred while fetching the character."; return isSlash ? interaction.editReply(msg) : interaction.reply(msg); } } // ─── Slash Command Registration ─────────────────────────────────────────────── async function registerSlashCommands() { const commands = [ new SlashCommandBuilder() .setName("char") .setDescription("Show a Sweetgum character preview") .addStringOption((opt) => opt.setName("name").setDescription("Character name").setRequired(true) ), new SlashCommandBuilder() .setName("rankings") .setDescription("Post the current top 10 rankings now"), ].map((c) => c.toJSON()); const rest = new REST({ version: "10" }).setToken(CONFIG.token); try { if (CONFIG.guildId) { // Guild-scoped (instant, good for testing) await rest.put(Routes.applicationGuildCommands(CONFIG.clientId, CONFIG.guildId), { body: commands }); console.log("✅ Guild slash commands registered."); } else { // Global (takes ~1 hour to propagate) await rest.put(Routes.applicationCommands(CONFIG.clientId), { body: commands }); console.log("✅ Global slash commands registered."); } } catch (err) { console.error("Failed to register slash commands:", err); } } // ─── Rankings ───────────────────────────────────────────────────────────────── /** Fetch top 10 characters by level then EXP, excluding GMs (job 900/910) */ async function fetchTopCharacters() { const db = await getDb(); const [rows] = await db.query( `SELECT id, name, level, job, gender, hair, face, skincolor, exp, meso FROM characters WHERE job NOT IN (900, 910) AND gm = 0 ORDER BY level DESC, exp DESC LIMIT 10` ); // Fetch equipped items for each character const characters = []; for (const char of rows) { const [items] = await db.query( `SELECT itemid, position FROM inventoryitems WHERE characterid = ? AND inventorytype = -1 ORDER BY position DESC`, [char.id] ); const SLOT_MAP = { '-1': 'cap', '-101': 'cap', '-2': 'mask', '-102': 'mask', '-3': 'eyes', '-103': 'eyes', '-4': 'ears', '-104': 'ears', '-5': 'coat', '-105': 'coat', '-6': 'pants', '-106': 'pants', '-7': 'shoes', '-107': 'shoes', '-8': 'glove', '-108': 'glove', '-9': 'cape', '-109': 'cape', '-10': 'shield','-110': 'shield', '-11': 'weapon','-111': 'weapon', }; const equipped = {}; for (const item of items) { const key = SLOT_MAP[String(item.position)]; if (key && (!equipped[key] || item.position <= -101)) { equipped[key] = item.itemid; } } characters.push({ ...char, equipped }); } return characters; } const MEDAL_EMOJIS = ["🥇", "🥈", "🥉"]; const RANK_COLORS = [0xFFD700, 0xC0C0C0, 0xCD7F32]; // gold, silver, bronze /** * Build a composite rankings image: * A column of character renders with their rank, name, level beside each one. */ async function buildRankingsImage(characters) { const CARD_W = 520; const CARD_H = 110; const SPRITE_W = 80; const PAD = 12; const TOTAL_H = CARD_H * characters.length + PAD * 2; const canvas = createCanvas(CARD_W, TOTAL_H); const ctx = canvas.getContext("2d"); // Background ctx.fillStyle = "#0f0f1a"; ctx.fillRect(0, 0, CARD_W, TOTAL_H); for (let i = 0; i < characters.length; i++) { const char = characters[i]; const y = PAD + i * CARD_H; // Card background — highlight top 3 ctx.fillStyle = i === 0 ? "#1a1500" : i === 1 ? "#111118" : i === 2 ? "#120a00" : "#111118"; ctx.fillRect(PAD, y, CARD_W - PAD * 2, CARD_H - 4); // Left accent bar (gold/silver/bronze/grey) ctx.fillStyle = i < 3 ? ["#FFD700","#C0C0C0","#CD7F32"][i] : "#333355"; ctx.fillRect(PAD, y, 4, CARD_H - 4); // Rank number ctx.font = "bold 22px Arial"; ctx.fillStyle = i < 3 ? ["#FFD700","#C0C0C0","#CD7F32"][i] : "#666688"; ctx.textAlign = "center"; ctx.fillText(`#${i + 1}`, PAD + 28, y + CARD_H / 2 + 8); // Character sprite try { const imgBuf = await generateCharacterImage(char); const img = await loadImage(imgBuf); // Draw sprite scaled to fit card height, preserving aspect ratio const scale = (CARD_H - 8) / img.height; const sw = img.width * scale; ctx.drawImage(img, PAD + 56, y + 4, sw, CARD_H - 8); } catch (e) { // Fallback: coloured placeholder ctx.fillStyle = "#333355"; ctx.fillRect(PAD + 56, y + 4, SPRITE_W, CARD_H - 8); } // Name ctx.textAlign = "left"; ctx.font = "bold 18px Arial"; ctx.fillStyle = "#ffffff"; ctx.fillText(char.name, PAD + 56 + SPRITE_W + 8, y + 32); // Job ctx.font = "14px Arial"; ctx.fillStyle = "#9999bb"; ctx.fillText(getJobName(char.job), PAD + 56 + SPRITE_W + 8, y + 52); // Level ctx.font = "bold 20px Arial"; ctx.fillStyle = "#e94560"; ctx.textAlign = "right"; ctx.fillText(`Lv.${char.level}`, CARD_W - PAD * 2, y + 38); // EXP bar at bottom of card const expNeeded = getExpForLevel(char.level); const expPct = expNeeded > 0 ? Math.min(1, Number(char.exp) / expNeeded) : 1; const barX = PAD + 56 + SPRITE_W + 8; const barW = CARD_W - PAD * 2 - 56 - SPRITE_W - 16; const barY = y + CARD_H - 20; const barH = 6; ctx.fillStyle = "#222233"; ctx.fillRect(barX, barY, barW, barH); ctx.fillStyle = i < 3 ? ["#FFD700","#C0C0C0","#CD7F32"][i] : "#e94560"; ctx.fillRect(barX, barY, Math.floor(barW * expPct), barH); ctx.font = "11px Arial"; ctx.fillStyle = "#666688"; ctx.textAlign = "left"; ctx.fillText(`${Math.floor(expPct * 100)}% to next level`, barX, barY + barH + 11); } return canvas.toBuffer("image/png"); } /** Post (or refresh) the daily rankings in the configured channel */ async function postRankings(client) { if (!CONFIG.rankingsChannelId) { console.warn("⚠️ RANKINGS_CHANNEL_ID not set — skipping rankings post."); return; } const channel = await client.channels.fetch(CONFIG.rankingsChannelId).catch(() => null); if (!channel) { console.error("❌ Could not find rankings channel."); return; } console.log("📊 Building daily rankings..."); try { const characters = await fetchTopCharacters(); if (characters.length === 0) { console.log("No characters found for rankings."); return; } const imageBuffer = await buildRankingsImage(characters); const attachment = new AttachmentBuilder(imageBuffer, { name: "rankings.png" }); // Build summary embed const now = new Date(); const embed = new EmbedBuilder() .setTitle("🏆 Daily Rankings") .setDescription( characters.map((c, i) => { const medal = MEDAL_EMOJIS[i] ?? `**#${i + 1}**`; return `${medal} **${c.name}** — Lv.${c.level} ${getJobName(c.job)}`; }).join("\n") ) .setImage("attachment://rankings.png") .setColor(0xFFD700) .setFooter({ text: "Sweetbot • Updates daily at 9:00 PM EST" }) .setTimestamp(now); // Delete previous rankings message if one exists try { const messages = await channel.messages.fetch({ limit: 20 }); const old = messages.find( (m) => m.author.id === client.user.id && m.embeds.length > 0 && m.embeds[0].title === "🏆 Daily Rankings" ); if (old) await old.delete(); } catch (e) { console.warn("Could not delete old rankings message:", e.message); } await channel.send({ embeds: [embed], files: [attachment] }); console.log("✅ Rankings posted."); } catch (err) { console.error("Error posting rankings:", err); } } /** * Schedule the daily rankings post at 9:00 PM EST (02:00 UTC next day). * Uses a simple setTimeout loop that recalculates the delay each time. */ function scheduleRankings(client) { function msUntilNext9pmEST() { const now = new Date(); // EST = UTC-5; target hour in UTC = 21 + 5 = 02:00 next day const target = new Date(); target.setUTCHours(2, 0, 0, 0); if (target <= now) target.setUTCDate(target.getUTCDate() + 1); return target - now; } function scheduleNext() { const delay = msUntilNext9pmEST(); const hrsAway = (delay / 1000 / 60 / 60).toFixed(2); console.log(`📅 Next rankings post in ${hrsAway} hours (9:00 PM EST).`); setTimeout(async () => { await postRankings(client); scheduleNext(); // reschedule for next day }, delay); } scheduleNext(); } // ─── Bot Client ─────────────────────────────────────────────────────────────── const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, ], }); client.once("ready", async () => { console.log(`✅ Logged in as ${client.user.tag}`); await registerSlashCommands(); scheduleRankings(client); }); // Slash command handler client.on("interactionCreate", async (interaction) => { if (!interaction.isChatInputCommand()) return; if (interaction.commandName === "char") { await handleCharCommand(interaction, interaction.options.getString("name")); } if (interaction.commandName === "rankings") { await interaction.deferReply({ ephemeral: true }); await postRankings(client); await interaction.editReply("✅ Rankings posted!"); } }); // Prefix command handler (!char ) client.on("messageCreate", async (message) => { if (message.author.bot) return; if (!message.content.startsWith(CONFIG.prefix)) return; const args = message.content.slice(CONFIG.prefix.length).trim().split(/\s+/); const cmd = args.shift().toLowerCase(); if (cmd === "char") { await handleCharCommand(message, args.join(" ")); } if (cmd === "rankings") { await postRankings(client); } }); client.login(CONFIG.token);