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