Add bot to repo
This commit is contained in:
687
bot.js
Normal file
687
bot.js
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* MapleCore Character Image Discord Bot
|
||||
* Standalone bot - no full webserver needed.
|
||||
*
|
||||
* Usage: !char <characterName>
|
||||
* /char name:<characterName>
|
||||
*
|
||||
* 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 <name>)
|
||||
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);
|
||||
17
env.example
Normal file
17
env.example
Normal file
@@ -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
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user