From e40594e107ceffaf769e51c50fd4fd15628da9ed Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Fri, 5 Jun 2026 03:07:51 +0100 Subject: [PATCH] fix wrank deltas sync, fix wrank no rank display unalignment --- .gitignore | 6 + messages/emojis.json | 107 ++++++++++--- scripts/upload-emojis.ts | 293 +++++++++++++++++++++-------------- src/subcommands/rank/post.ts | 9 -- src/systems/format.ts | 15 +- src/systems/poll.ts | 62 ++++---- src/systems/wrank.ts | 14 +- tsconfig.json | 1 + 8 files changed, 327 insertions(+), 180 deletions(-) diff --git a/.gitignore b/.gitignore index 2a28802..b5bfe3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Dependencies node_modules/ +docker-compose.yml + # Environment variables — never commit these .env @@ -8,6 +10,7 @@ node_modules/ data/config.json data/characters.json data/accounts.json +data/poll-state.json data/usermap.json data/wrank.json data/bringer.json @@ -17,6 +20,9 @@ data/tg-history/ # Emoji data emoji-uploads/ +# Tests +tests/ + # Keep the data directory structure but not the contents !data/.gitkeep !data/tg-history/.gitkeep diff --git a/messages/emojis.json b/messages/emojis.json index 5f14e99..426f173 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -16,26 +16,91 @@ "storm_bringer": "<:storm_bringer:1511906496097554594>", "wa": "<:wa:1511906499889467492>", "wi": "<:wi:1511906503647563807>", - "wrank_1": "<:wrank_1:1511906507485085736>", - "wrank_1_gold": "<:wrank_1_gold:1511906510806978742>", - "wrank_2": "<:wrank_2:1511906514745430217>", - "wrank_2_gold": "<:wrank_2_gold:1511906518386212864>", - "wrank_3": "<:wrank_3:1511906522265944154>", - "wrank_3_gold": "<:wrank_3_gold:1511906526204530690>", - "wrank_4": "<:wrank_4:1511906530692173915>", - "wrank_4_gold": "<:wrank_4_gold:1511906534790266883>", - "wrank_5": "<:wrank_5:1511906539223388322>", - "wrank_5_gold": "<:wrank_5_gold:1511906543342452837>", + "wrank_1": "<:wrank_1:1512124887592996995>", + "wrank_1_gold": "<:wrank_1_gold:1512125051728560278>", + "wrank_2": "<:wrank_2:1512124931075342376>", + "wrank_2_gold": "<:wrank_2_gold:1512125095974535271>", + "wrank_3": "<:wrank_3:1512124938453254334>", + "wrank_3_gold": "<:wrank_3_gold:1512125103964684390>", + "wrank_4": "<:wrank_4:1512124943465316433>", + "wrank_4_gold": "<:wrank_4_gold:1512125108154663133>", + "wrank_5": "<:wrank_5:1512124947852431513>", + "wrank_5_gold": "<:wrank_5_gold:1512125112084594818>", "wrank_down": "<:wrank_down:1511906547104616643>", - "wrank_down_1": "<:wrank_down_1:1511906550698999909>", - "wrank_down_2": "<:wrank_down_2:1511906554507694120>", - "wrank_down_3": "<:wrank_down_3:1511906558231969792>", - "wrank_down_4": "<:wrank_down_4:1511906562011304007>", - "wrank_down_5": "<:wrank_down_5:1511906565630984273>", - "wrank_up": "<:wrank_up:1511906568877117576>", - "wrank_up_1": "<:wrank_up_1:1511906573537120287>", - "wrank_up_2": "<:wrank_up_2:1511906577970364536>", - "wrank_up_3": "<:wrank_up_3:1511906581711945909>", - "wrank_up_4": "<:wrank_up_4:1511906585503338616>", - "wrank_up_5": "<:wrank_up_5:1511906588921954325>" + "wrank_down_1": "<:wrank_down_1:1512124970698801244>", + "wrank_down_2": "<:wrank_down_2:1512125016114729133>", + "wrank_down_3": "<:wrank_down_3:1512125023199166536>", + "wrank_down_4": "<:wrank_down_4:1512125027372237040>", + "wrank_down_5": "<:wrank_down_5:1512125030765691072>", + "wrank_neutral": "<:wrank_neutral:1511950713713070160>", + "wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>", + "wrank_no_dash": "<:wrank_no_dash:1511956379403943979>", + "wrank_up_1": "<:wrank_up_1:1512125132242554890>", + "wrank_up_10": "<:wrank_up_10:1512125136445243503>", + "wrank_up_2": "<:wrank_up_2:1512127569259135139>", + "wrank_up_3": "<:wrank_up_3:1512127577051893843>", + "wrank_up_4": "<:wrank_up_4:1512127582068281455>", + "wrank_up_6": "<:wrank_up_6:1512127590062620723>", + "wrank_up_7": "<:wrank_up_7:1512127593883766978>", + "wrank_up_8": "<:wrank_up_8:1512127598044643428>", + "wrank_up_9": "<:wrank_up_9:1512127601811128501>", + "wrank_up": "<:wrank_up:1512114414474756132>", + "wrank_up_5": "<:wrank_up_5:1512127585826377928>", + "wrank_up_11": "<:wrank_up_11:1512125140454998018>", + "wrank_up_12": "<:wrank_up_12:1512125144984719630>", + "wrank_up_13": "<:wrank_up_13:1512125149057388667>", + "wrank_up_14": "<:wrank_up_14:1512125153671123124>", + "wrank_up_15": "<:wrank_up_15:1512127541995901100>", + "wrank_up_16": "<:wrank_up_16:1512127545753993439>", + "wrank_up_17": "<:wrank_up_17:1512127549956821002>", + "wrank_up_18": "<:wrank_up_18:1512127553995931959>", + "wrank_up_19": "<:wrank_up_19:1512127558143971534>", + "wrank_up_20": "<:wrank_up_20:1512127573092597840>", + "wrank_10": "<:wrank_10:1512124891250561096>", + "wrank_11": "<:wrank_11:1512124894694080576>", + "wrank_12": "<:wrank_12:1512124898611429387>", + "wrank_13": "<:wrank_13:1512124902831030282>", + "wrank_14": "<:wrank_14:1512124907511611537>", + "wrank_15": "<:wrank_15:1512124911550730452>", + "wrank_16": "<:wrank_16:1512124915367673886>", + "wrank_17": "<:wrank_17:1512124919029305434>", + "wrank_18": "<:wrank_18:1512124923018219721>", + "wrank_19": "<:wrank_19:1512124927262855239>", + "wrank_20": "<:wrank_20:1512124934762135684>", + "wrank_6": "<:wrank_6:1512124952738795581>", + "wrank_7": "<:wrank_7:1512124956622979143>", + "wrank_8": "<:wrank_8:1512124961450496020>", + "wrank_9": "<:wrank_9:1512124965363650631>", + "wrank_down_10": "<:wrank_down_10:1512124974582989000>", + "wrank_down_11": "<:wrank_down_11:1512124978504536114>", + "wrank_down_12": "<:wrank_down_12:1512124982728069192>", + "wrank_down_13": "<:wrank_down_13:1512124987501314150>", + "wrank_down_14": "<:wrank_down_14:1512124991292837918>", + "wrank_down_15": "<:wrank_down_15:1512124995340468335>", + "wrank_down_16": "<:wrank_down_16:1512124999576850462>", + "wrank_down_17": "<:wrank_down_17:1512125004353896642>", + "wrank_down_18": "<:wrank_down_18:1512125008132964486>", + "wrank_down_19": "<:wrank_down_19:1512125011857510410>", + "wrank_down_20": "<:wrank_down_20:1512125019814101173>", + "wrank_down_6": "<:wrank_down_6:1512125035123576922>", + "wrank_down_7": "<:wrank_down_7:1512125039091126434>", + "wrank_down_8": "<:wrank_down_8:1512125042757210123>", + "wrank_down_9": "<:wrank_down_9:1512125047798759706>", + "wrank_10_gold": "<:wrank_10_gold:1512125055432396910>", + "wrank_11_gold": "<:wrank_11_gold:1512125059123249242>", + "wrank_12_gold": "<:wrank_12_gold:1512125063304974438>", + "wrank_13_gold": "<:wrank_13_gold:1512125067201609908>", + "wrank_14_gold": "<:wrank_14_gold:1512125071043596520>", + "wrank_15_gold": "<:wrank_15_gold:1512125074893832443>", + "wrank_16_gold": "<:wrank_16_gold:1512125078966374420>", + "wrank_17_gold": "<:wrank_17_gold:1512125083605532715>", + "wrank_18_gold": "<:wrank_18_gold:1512125088378392718>", + "wrank_19_gold": "<:wrank_19_gold:1512125091960459508>", + "wrank_20_gold": "<:wrank_20_gold:1512125100265181204>", + "wrank_6_gold": "<:wrank_6_gold:1512125115956203601>", + "wrank_7_gold": "<:wrank_7_gold:1512125120204771338>", + "wrank_8_gold": "<:wrank_8_gold:1512125123874918661>", + "wrank_9_gold": "<:wrank_9_gold:1512125128299905104>", + "wrank_no_rank": "<:wrank_no_rank:1512261782205628606>", + "wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>" } \ No newline at end of file diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts index 80ea997..8e7cafb 100644 --- a/scripts/upload-emojis.ts +++ b/scripts/upload-emojis.ts @@ -1,22 +1,27 @@ /** - * Bulk emoji upload script - * Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] + * Bulk emoji upload script with subdirectory support and round-robin distribution. * - * Distributes emojis across a pool of donor servers (round-robin by available capacity). - * Each emoji is unique across all servers — no duplicates. - * Automatically updates messages/emojis.json with the uploaded emoji IDs. + * Usage: + * Upload: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] + * Delete: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts --delete + * Pattern can be a prefix (e.g. "wrank_up") or exact name (e.g. "wrank_up_1") + * Multiple patterns: --delete wrank_up wrank_down wrank_gold + * + * Directory naming conventions: + * - Files in root dir → name = filename without extension + * - Files in subdir → name determined by DIR_NAME_MAP or default (dirname_filename) + * - Passthrough dirs → name = filename only (no prefix) * * Required .env vars: - * DISCORD_TOKEN — bot token - * EMOJI_DONOR_GUILDS — comma-separated donor server IDs - * e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333 + * DISCORD_TOKEN — bot token + * EMOJI_DONOR_GUILDS — comma-separated donor server IDs */ import { REST, Routes } from "discord.js"; import fs from "fs"; import path from "path"; - // Load .env manually since we're outside the bot runtime + // Load .env const envPath = path.join(__dirname, "../.env"); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { @@ -27,185 +32,239 @@ const TOKEN = process.env.DISCORD_TOKEN!; const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean); + .split(",").map((id) => id.trim()).filter(Boolean); - if (!TOKEN) { - console.error("❌ DISCORD_TOKEN must be set in .env"); - process.exit(1); - } - if (DONOR_GUILD_IDS.length === 0) { - console.error("❌ EMOJI_DONOR_GUILDS must be set in .env (comma-separated guild IDs)"); + if (!TOKEN || DONOR_GUILD_IDS.length === 0) { + console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env"); process.exit(1); } - const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads"); + const emojiDir = path.join(__dirname, "../emoji-uploads"); const emojisPath = path.join(__dirname, "../messages/emojis.json"); + const rest = new REST({ version: "10" }).setToken(TOKEN); - if (!fs.existsSync(emojiDir)) { - console.error(`❌ Emoji directory not found: ${emojiDir}`); - console.error(` Create it and place your emoji PNG files inside.`); - process.exit(1); + // ─── Naming config ───────────────────────────────────────────────────────────── + + // Dirs listed here use filename only — no dir prefix + const PASSTHROUGH_DIRS: string[] = ["classes", "nations", "misc"]; + + // Custom naming functions per dir — (filename without ext) → emoji name + const DIR_NAME_MAP: Record string> = { + "wrank": (f) => `wrank_${f}`, + "wrank_gold": (f) => `wrank_${f}_gold`, + "wrank_up": (f) => `wrank_up_${f}`, + "wrank_down": (f) => `wrank_down_${f}`, + "wrank_x": (f) => `wrank_x_${f}`, + }; + + function resolveEmojiName(dirName: string, filename: string): string { + if (PASSTHROUGH_DIRS.includes(dirName)) return filename; + if (DIR_NAME_MAP[dirName]) return DIR_NAME_MAP[dirName](filename); + return `${dirName}_${filename}`; // default: dirname_filename } - const rest = new REST({ version: "10" }).setToken(TOKEN); + // ─── File discovery ──────────────────────────────────────────────────────────── - interface GuildEmojiSlot { - guildId: string; - name: string; // guild name for display + interface EmojiFile { + emojiName: string; + filePath: string; + mimeType: string; + } + + const IMAGE_EXTS = [".png", ".jpg", ".gif", ".webp"]; + + function mimeFor(ext: string): string { + if (ext === ".gif") return "image/gif"; + if (ext === ".webp") return "image/webp"; + return "image/png"; + } + + function scanDir(dir: string, parentDirName?: string): EmojiFile[] { + const results: EmojiFile[] = []; + if (!fs.existsSync(dir)) return results; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...scanDir(fullPath, entry.name)); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (!IMAGE_EXTS.includes(ext)) continue; + const filename = path.basename(entry.name, ext); + const emojiName = parentDirName + ? resolveEmojiName(parentDirName, filename) + : filename; + results.push({ emojiName, filePath: fullPath, mimeType: mimeFor(ext) }); + } + } + return results; + } + + // ─── Guild helpers ───────────────────────────────────────────────────────────── + + interface GuildSlot { + guildId: string; + name: string; existing: Map; // emojiName → emojiId capacity: number; } - // Compute max emojis based on Nitro boost tier - function maxEmojisForTier(premiumTier: number): number { - switch (premiumTier) { - case 1: return 100; - case 2: return 150; - case 3: return 250; - default: return 50; - } + function maxEmojisForTier(tier: number): number { + return [50, 100, 150, 250][tier] ?? 50; } - async function fetchGuildSlots(guildIds: string[]): Promise { - const slots: GuildEmojiSlot[] = []; - - for (const guildId of guildIds) { + async function fetchGuildSlots(): Promise { + const slots: GuildSlot[] = []; + for (const guildId of DONOR_GUILD_IDS) { try { const [guild, emojis] = await Promise.all([ rest.get(Routes.guild(guildId)) as Promise, rest.get(Routes.guildEmojis(guildId)) as Promise, ]); - - const maxEmojis = maxEmojisForTier(guild.premium_tier ?? 0); - const existingMap = new Map(emojis.map((e: any) => [e.name, e.id])); - const capacity = maxEmojis - emojis.length; - const guildName = guild.name ?? guildId; - - console.log(`🏠 ${guildName} (${guildId}): ${emojis.length}/${maxEmojis} emojis, ${capacity} slots free`); - slots.push({ guildId, name: guildName, existing: existingMap, capacity }); + const max = maxEmojisForTier(guild.premium_tier ?? 0); + const existing = new Map(emojis.map((e: any) => [e.name, e.id])); + const capacity = max - emojis.length; + console.log(`🏠 ${guild.name} (${guildId}): ${emojis.length}/${max} emojis, ${capacity} free`); + slots.push({ guildId, name: guild.name, existing, capacity }); } catch (err: any) { console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`); } } - return slots; } - async function uploadEmojis(): Promise { - const files = fs.readdirSync(emojiDir).filter((f) => - [".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase()) - ); + // ─── Upload ──────────────────────────────────────────────────────────────────── + async function upload(): Promise { + const files = scanDir(emojiDir); if (files.length === 0) { - console.error("❌ No image files found in the emoji directory."); + console.error(`❌ No image files found in ${emojiDir}`); process.exit(1); } - // Load existing emojis.json let emojiMap: Record = {}; - try { - emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); - } catch { - console.warn("⚠️ Could not load emojis.json — will create fresh mapping."); - } + try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} - console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`); - console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`); + console.log(`\n📁 Found ${files.length} file(s)\n🔍 Scanning donor servers...\n`); + const slots = await fetchGuildSlots(); + if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } - const guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS); - - if (guildSlots.length === 0) { - console.error("❌ No accessible donor guilds found. Check EMOJI_DONOR_GUILDS and bot membership."); - process.exit(1); - } - - // Build global deduplication map across all donor guilds - const globalExisting = new Map(); // emojiName → formatted string - for (const slot of guildSlots) { + // Build global dedup map + const globalExisting = new Map(); + for (const slot of slots) { for (const [name, id] of slot.existing) { globalExisting.set(name, `<:${name}:${id}>`); } } - const totalCapacity = guildSlots.reduce((sum, s) => sum + s.capacity, 0); - console.log(`\n📊 ${globalExisting.size} emoji(s) already exist · ${totalCapacity} slots available across all servers\n`); + const totalCapacity = slots.reduce((s, g) => s + g.capacity, 0); + console.log(`\n📊 ${globalExisting.size} existing · ${totalCapacity} slots free\n`); - if (totalCapacity === 0) { - console.error("❌ All donor servers are full! Add more servers to EMOJI_DONOR_GUILDS."); - process.exit(1); - } - - let uploaded = 0; - let skipped = 0; - let failed = 0; - - // Round-robin slot picker — distributes load evenly across guilds let slotIndex = 0; - function nextAvailableSlot(): GuildEmojiSlot | null { + function nextSlot(): GuildSlot | null { const start = slotIndex; do { - const slot = guildSlots[slotIndex % guildSlots.length]; + const s = slots[slotIndex % slots.length]; slotIndex++; - if (slot.capacity > 0) return slot; - } while (slotIndex % guildSlots.length !== start % guildSlots.length); - // Fallback: find any with capacity (in case loop exited without finding one) - return guildSlots.find((s) => s.capacity > 0) ?? null; + if (s.capacity > 0) return s; + } while (slotIndex % slots.length !== start % slots.length); + return slots.find((s) => s.capacity > 0) ?? null; } - for (const file of files) { - const emojiName = path.basename(file, path.extname(file)); - const filePath = path.join(emojiDir, file); - const ext = path.extname(file).toLowerCase(); - const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png"; + let uploaded = 0, skipped = 0, failed = 0; - // Already exists in the pool — update map and skip - if (globalExisting.has(emojiName)) { - emojiMap[emojiName] = globalExisting.get(emojiName)!; - console.log(`⏭️ Already exists: ${emojiName} → ${emojiMap[emojiName]}`); + for (const file of files) { + if (globalExisting.has(file.emojiName)) { + emojiMap[file.emojiName] = globalExisting.get(file.emojiName)!; + console.log(`⏭️ Exists: ${file.emojiName} → ${emojiMap[file.emojiName]}`); skipped++; continue; } - const slot = nextAvailableSlot(); + const slot = nextSlot(); if (!slot) { - console.error(`❌ All slots full — could not upload: ${emojiName}`); - console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`); + console.error(`❌ No slots available for: ${file.emojiName}`); failed++; continue; } try { - const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; - const result = await rest.post(Routes.guildEmojis(slot.guildId), { - body: { name: emojiName, image: base64 }, + const base64 = `data:${file.mimeType};base64,${fs.readFileSync(file.filePath).toString("base64")}`; + const result = await rest.post(Routes.guildEmojis(slot.guildId), { + body: { name: file.emojiName, image: base64 }, }) as any; - const formatted = `<:${emojiName}:${result.id}>`; - emojiMap[emojiName] = formatted; + const formatted = `<:${file.emojiName}:${result.id}>`; + emojiMap[file.emojiName] = formatted; slot.capacity--; - - console.log(`✅ Uploaded: ${emojiName} → ${formatted} [${slot.name}]`); + console.log(`✅ Uploaded: ${file.emojiName} → ${formatted} [${slot.name}]`); uploaded++; - // Rate limit buffer await new Promise((r) => setTimeout(r, 600)); } catch (err: any) { - console.error(`❌ Failed: ${emojiName} — ${err.message}`); + console.error(`❌ Failed: ${file.emojiName} — ${err.message}`); failed++; } } fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); - console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); console.log(`💾 messages/emojis.json updated`); - console.log(`\nSlot usage after upload:`); - for (const slot of guildSlots) { - const used = slot.existing.size + (uploaded > 0 ? slot.existing.size : 0); - console.log(` ${slot.name}: ${slot.capacity} slots remaining`); - } } - uploadEmojis().catch(console.error); \ No newline at end of file + // ─── Delete ──────────────────────────────────────────────────────────────────── + + async function deleteEmojis(patterns: string[]): Promise { + console.log(`\n🗑️ Deleting emojis matching: ${patterns.join(", ")}`); + console.log(`🔍 Scanning donor servers...\n`); + + const slots = await fetchGuildSlots(); + if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } + + let emojiMap: Record = {}; + try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} + + let deleted = 0, failed = 0; + + for (const slot of slots) { + for (const [name, id] of slot.existing) { + const matches = patterns.some((p) => name === p || name.startsWith(`${p}_`) || name.startsWith(p)); + if (!matches) continue; + + try { + await rest.delete(Routes.guildEmoji(slot.guildId, id)); + console.log(`🗑️ Deleted: ${name} [${slot.name}]`); + slot.existing.delete(name); + delete emojiMap[name]; + deleted++; + await new Promise((r) => setTimeout(r, 300)); + } catch (err: any) { + console.error(`❌ Failed to delete ${name}: ${err.message}`); + failed++; + } + } + } + + fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); + console.log(`\n📊 ${deleted} deleted · ${failed} failed`); + console.log(`💾 messages/emojis.json updated`); + } + + // ─── Entry point ─────────────────────────────────────────────────────────────── + + const args = process.argv.slice(2); + + if (args[0] === "--delete") { + const patterns = args.slice(1); + if (patterns.length === 0) { + console.error("❌ Specify at least one pattern: --delete [pattern2] ..."); + console.error(" Examples:"); + console.error(" --delete wrank_up (deletes wrank_up_1, wrank_up_2, ...)"); + console.error(" --delete wrank_up_1 (deletes exact match)"); + console.error(" --delete wrank_up wrank_down wrank_gold"); + process.exit(1); + } + deleteEmojis(patterns).catch(console.error); + } else { + upload().catch(console.error); + } \ No newline at end of file diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index 8981b3c..c6ec08b 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -49,15 +49,6 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): ) .setTimestamp(); - // const embed = new EmbedBuilder() - // .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`) - // .setColor(0xe8a317) - // .addFields( - // { name: "🔵 Capella", value: formatNation("capella"), inline: true }, - // { name: "🔴 Procyon", value: formatNation("procyon"), inline: true }, - // ) - // .setTimestamp(); - const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); const channel = await interaction.client.channels.fetch(channelId) as TextChannel; await channel.send({ embeds: [embed] }); diff --git a/src/systems/format.ts b/src/systems/format.ts index 7205b43..3af6630 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -92,7 +92,7 @@ function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean }): string const numEmoji = getEmoji(`wrank_down_${delta}`); inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta); } else { - inner = (getEmoji("wrank_neutral") || "·") + "0"; + inner = (getEmoji("wrank_no_dash") || "·") + (getEmoji("wrank_neutral_0") || "0"); } return brackets ? ` (${inner})` : ` ${inner}`; @@ -106,6 +106,18 @@ function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string { return wrankRank(entry, options.goal) + wrankDelta(entry, { brackets: options.brackets }); } +/** + * Placeholder for characters with no W.Rank when others in their nation have one. + * Output: — ( [] — ) + */ + function wrankNoRank(): string { + const norank = getEmoji("wrank_no_dash") || "—"; + const dash = getEmoji("wrank_no_rank_delta") || "—"; + const square = getEmoji("wrank_no_dash") || "■"; + return `${norank} (${square}${dash})`; +} + + // ─── Namespace export ───────────────────────────────────────────────────────── export const format = { @@ -117,5 +129,6 @@ export const format = { rank: wrankRank, delta: wrankDelta, full: wrankFull, + noRank: wrankNoRank, }, }; \ No newline at end of file diff --git a/src/systems/poll.ts b/src/systems/poll.ts index efab526..d6e1ca7 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -69,18 +69,45 @@ export function lockPoll(slot: number): void { persist.save(polls) } - + // ─── Character display ──────────────────────────────────────────────────────── -function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { +function getNationBringerTitle(nation: Nation) { + const stormBringerIcon = getEmoji("storm_bringer") || "⚡"; + const stormBringer = `${stormBringerIcon}`; + + const luminousBringerIcon = getEmoji("luminous_bringer") || "🔆"; + const luminousBringer = `${luminousBringerIcon}` || `🔆 Luminous Bringer`; + + const nationMap = { + "Capella": luminousBringer, + "Procyon": stormBringer + }; + + return nationMap[nation]; +} + +function getBringerDisplay(nation: Nation): string { + const bringerMap: Record = { + Capella: getEmoji("luminous_bringer") || "🔆 Luminous Bringer", + Procyon: getEmoji("storm_bringer") || "⚡ Storm Bringer", + }; + return bringerMap[nation]; +} + +function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank = false): string { const cfgFormat = cfg("charDisplayFormat"); const nation = entry.characterNation; - const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; + const wRankEntry = entry.characterName && entry.characterNation + ? getEntry(entry.characterName, entry.characterNation) + : null; let wrank = ""; if (wRankEntry) { const wRankGoal = cfg("wRankGoal"); wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true }); + } else if (nationHasRank) { + wrank = format.wrank.noRank(); } const classStr = entry.characterClass @@ -100,33 +127,11 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { .trim(); // Bringer title — independent of W.Rank so override always shows - function getNationBringerTitle(nation: Nation) { - const stormBringerIcon = getEmoji("storm_bringer") || "⚡"; - const stormBringer = `${stormBringerIcon}`; - - const luminousBringerIcon = getEmoji("luminous_bringer") || "⚡"; - const luminousBringer = `${luminousBringerIcon}` || `⚡ Luminous Bringer`; - - const nationMap = { - "Capella": luminousBringer, - "Procyon": stormBringer - }; - - return nationMap[nation]; - } if (nation && entry.userKey) { const bringer = getBringer(nation); if (bringer && bringer === entry.characterName) { - const bringerTitle = getNationBringerTitle(nation); - row += ` · ${bringerTitle}`; + row += ` · ${getBringerDisplay(nation)}`; } - // if (bringer && bringer === entry.characterName) { - // const emoji = nation === "Capella" - // ? (getEmoji("luminous_bringer") || "🔆") - // : (getEmoji("storm_bringer") || "⚡"); - // const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; - // row += ` · ${emoji} **${title}**`; - // } } if (entry.borrowedFrom) { @@ -160,12 +165,13 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui const formatNationField = (nation: Nation): string => { const yesEntries = yesByNation[nation]; + const hasRank = yesEntries.some((e) => e.characterName && getEntry(e.characterName, nation) !== null); const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : []; const lines = [ - ...yesEntries.map((e) => formatCharRow(e)), - ...noEntries.map((e) => `❌ ${formatCharRow(e)}`), + ...yesEntries.map((e) => formatCharRow(e, false, hasRank)), + ...noEntries.map((e) => `❌ ${formatCharRow(e, false, hasRank)}`), ]; return lines.length > 0 ? lines.join("\n") : "—"; }; diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 63f6a0a..3c7e919 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -99,12 +99,16 @@ export function recordScore( } function recomputeRanks(week: WRankWeek, nation: Nation): void { - const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; + const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints); sorted.forEach((entry, i) => { - const live = list.find((e) => e.characterName === entry.characterName)!; - live.previousRank = live.currentRank || undefined; - live.currentRank = i + 1; + const live = list.find((e) => e.characterName === entry.characterName)!; + const newRank = i + 1; + // Only snapshot previousRank when rank actually changes + if (live.currentRank !== 0 && live.currentRank !== newRank) { + live.previousRank = live.currentRank; + } + live.currentRank = newRank; }); } @@ -146,6 +150,8 @@ export function getBringer(nation: Nation): string | null { export function getEntry(characterName: string, nation: Nation): WRankEntry | null { const week = getCurrentWeek(); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; + console.log(`[getEntry] weekKey=${week.weekKey} nation=${nation} listLength=${list?.length} looking for=${characterName}`); + console.log(`[getEntry] available:`, list?.map(e => e.characterName)); return list.find((e) => e.characterName === characterName) ?? null; } diff --git a/tsconfig.json b/tsconfig.json index 4752ddc..d5f7ea7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "paths": { "@src/*": ["src/*"], "@data/*": ["data/*"], + "@tests/*": ["tests/*"], "@messages/*": ["messages/*"], "@tgHistory/*": ["data/tg-history/*"], "@scripts/*": ["scripts/*"],