diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..7a31611 --- /dev/null +++ b/bot.js @@ -0,0 +1,687 @@ +/** + * 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); diff --git a/env.example b/env.example new file mode 100644 index 0000000..9bbaef6 --- /dev/null +++ b/env.example @@ -0,0 +1,17 @@ +# ─── Discord ────────────────────────────────────────────────────────────────── +DISCORD_TOKEN=your_bot_token_here +DISCORD_CLIENT_ID=your_application_client_id +DISCORD_GUILD_ID=your_guild_id_here # Remove or leave blank for global slash commands + +# Prefix for !char and !rankings commands (default: !) +BOT_PREFIX=! + +# Channel ID where daily rankings will be posted (right-click channel → Copy Channel ID) +RANKINGS_CHANNEL_ID=your_channel_id_here + +# ─── Database (same as MapleCore .env.local) ────────────────────────────────── +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=root +DB_NAME=cosmic diff --git a/package.json b/package.json new file mode 100644 index 0000000..204623c --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "maplecore-discord-bot", + "version": "1.0.0", + "description": "Standalone MapleCore v83 character preview Discord bot", + "main": "bot.js", + "scripts": { + "start": "node bot.js", + "dev": "node --watch bot.js" + }, + "dependencies": { + "axios": "^1.7.2", + "canvas": "^2.11.2", + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "mysql2": "^3.10.1" + } +}