diff --git a/.env b/.env index 8705b81..b0d98a2 100644 --- a/.env +++ b/.env @@ -5,9 +5,12 @@ SCORE_CHANNEL_ID=1511006435079884991 CLIENT_ID=1510959814623105044 GUILD_ID=1511006171681652858 EPHEMERAL_DELETE_MS=0 -POLL_EPHEMERAL_ENABLED=true # voting messages (disabled during testing) +POLL_EPHEMERAL_ENABLED=false # voting messages (disabled during testing) COMMAND_EPHEMERAL_ENABLED=true # command outputs (always on) AUTO_VOTE_ON_CONFLICT_SWITCH=true IMPERSONATE_RESET_ON_POLL=false IMPERSONATE_INDICATOR=true -RECLAIM_NOTIFY_BORROWER=true \ No newline at end of file +RECLAIM_NOTIFY_BORROWER=true + +# Emoji upload servers +EMOJI_DONOR_GUILDS=1511903882224336926,1511904145810915449 \ No newline at end of file diff --git a/data/characters.json b/data/characters.json index db2f30c..9357e9b 100644 --- a/data/characters.json +++ b/data/characters.json @@ -6,7 +6,7 @@ "class": "FB", "level": 79, "nation": "Procyon", - "active": true, + "active": false, "sharedWith": [ "invicjusz" ] @@ -16,7 +16,7 @@ "class": "WI", "level": 79, "nation": "Procyon", - "active": false, + "active": true, "sharedWith": [ "invicjusz" ] diff --git a/data/poll-state.json b/data/poll-state.json new file mode 100644 index 0000000..7700aba --- /dev/null +++ b/data/poll-state.json @@ -0,0 +1,26 @@ +[ + { + "messageId": "1511906795667456040", + "slot": 20, + "yes": [ + [ + "164487045052497920", + { + "userKey": "flash", + "displayName": "flash", + "characterName": "»Flash«", + "characterClass": "WI", + "characterLevel": 79, + "characterNation": "Procyon", + "discordId": "164487045052497920", + "votedAt": "03:53", + "previousNoAt": "03:53", + "publicMessage": "Flash? Flash? Flash!!" + } + ] + ], + "no": [], + "locked": false, + "confirmed": null + } +] \ No newline at end of file diff --git a/data/wrank.json b/data/wrank.json index e03ed18..40f9992 100644 --- a/data/wrank.json +++ b/data/wrank.json @@ -2,16 +2,76 @@ "2026-W23": { "weekKey": "2026-W23", "entries": { - "capella": [], + "capella": [ + { + "userKey": "zephyr", + "characterName": "XefronYokuda", + "class": "FA", + "nation": "Capella", + "weeklyPoints": 1415, + "tgCount": 2, + "currentRank": 4, + "previousRank": 3 + }, + { + "userKey": "dey", + "characterName": "«Deystroyer»", + "class": "BL", + "nation": "Capella", + "weeklyPoints": 3640, + "tgCount": 2, + "currentRank": 2, + "previousRank": 1 + }, + { + "userKey": "keira", + "characterName": "«Keira»", + "class": "WI", + "nation": "Capella", + "weeklyPoints": 4000, + "tgCount": 1, + "currentRank": 1, + "previousRank": 2 + }, + { + "userKey": "sean", + "characterName": "»No.1«", + "class": "FB", + "nation": "Capella", + "weeklyPoints": 1666, + "tgCount": 1, + "currentRank": 3 + } + ], "procyon": [ { - "usermapKey": "flash", + "userKey": "flash", "characterName": "»Flash«", "class": "WI", "nation": "Procyon", - "weeklyPoints": 1861.1111111111113, - "tgCount": 3, + "weeklyPoints": 5179, + "tgCount": 7, "currentRank": 1, + "previousRank": 2 + }, + { + "userKey": "invicjusz", + "characterName": "ElementalEnchant", + "class": "FB", + "nation": "Procyon", + "weeklyPoints": 2503, + "tgCount": 2, + "currentRank": 3, + "previousRank": 3 + }, + { + "userKey": "ayana", + "characterName": "«MonkeyHunter»", + "class": "DM", + "nation": "Procyon", + "weeklyPoints": 4741, + "tgCount": 2, + "currentRank": 2, "previousRank": 1 } ] @@ -19,7 +79,28 @@ "scoreIndex": { "flash": [ "2026-06-01-20", - "2026-06-01-22", + "2026-06-02-20" + ], + "invicjusz": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "ayana": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "zephyr": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "dey": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "keira": [ + "2026-06-02-20" + ], + "sean": [ "2026-06-02-20" ] }, diff --git a/emoji-uploads/luminous_bringer.png b/emoji-uploads/luminous_bringer.png new file mode 100644 index 0000000..9471257 Binary files /dev/null and b/emoji-uploads/luminous_bringer.png differ diff --git a/emoji-uploads/storm_bringer.png b/emoji-uploads/storm_bringer.png new file mode 100644 index 0000000..335cbfe Binary files /dev/null and b/emoji-uploads/storm_bringer.png differ diff --git a/emoji-uploads/wrank_1.png b/emoji-uploads/wrank_1.png new file mode 100644 index 0000000..1b3acc6 Binary files /dev/null and b/emoji-uploads/wrank_1.png differ diff --git a/emoji-uploads/wrank_1_gold.png b/emoji-uploads/wrank_1_gold.png new file mode 100644 index 0000000..ce060d4 Binary files /dev/null and b/emoji-uploads/wrank_1_gold.png differ diff --git a/emoji-uploads/wrank_2.png b/emoji-uploads/wrank_2.png new file mode 100644 index 0000000..0c7a413 Binary files /dev/null and b/emoji-uploads/wrank_2.png differ diff --git a/emoji-uploads/wrank_2_gold.png b/emoji-uploads/wrank_2_gold.png new file mode 100644 index 0000000..5a51605 Binary files /dev/null and b/emoji-uploads/wrank_2_gold.png differ diff --git a/emoji-uploads/wrank_3.png b/emoji-uploads/wrank_3.png new file mode 100644 index 0000000..40eb6f5 Binary files /dev/null and b/emoji-uploads/wrank_3.png differ diff --git a/emoji-uploads/wrank_3_gold.png b/emoji-uploads/wrank_3_gold.png new file mode 100644 index 0000000..f71621f Binary files /dev/null and b/emoji-uploads/wrank_3_gold.png differ diff --git a/emoji-uploads/wrank_4.png b/emoji-uploads/wrank_4.png new file mode 100644 index 0000000..a5c9ccd Binary files /dev/null and b/emoji-uploads/wrank_4.png differ diff --git a/emoji-uploads/wrank_4_gold.png b/emoji-uploads/wrank_4_gold.png new file mode 100644 index 0000000..e8654cb Binary files /dev/null and b/emoji-uploads/wrank_4_gold.png differ diff --git a/emoji-uploads/wrank_5.png b/emoji-uploads/wrank_5.png new file mode 100644 index 0000000..c5504a5 Binary files /dev/null and b/emoji-uploads/wrank_5.png differ diff --git a/emoji-uploads/wrank_5_gold.png b/emoji-uploads/wrank_5_gold.png new file mode 100644 index 0000000..af5e9ea Binary files /dev/null and b/emoji-uploads/wrank_5_gold.png differ diff --git a/emoji-uploads/wrank_down.png b/emoji-uploads/wrank_down.png new file mode 100644 index 0000000..e74e77b Binary files /dev/null and b/emoji-uploads/wrank_down.png differ diff --git a/emoji-uploads/wrank_down_1.png b/emoji-uploads/wrank_down_1.png new file mode 100644 index 0000000..071343f Binary files /dev/null and b/emoji-uploads/wrank_down_1.png differ diff --git a/emoji-uploads/wrank_down_2.png b/emoji-uploads/wrank_down_2.png new file mode 100644 index 0000000..8c99b29 Binary files /dev/null and b/emoji-uploads/wrank_down_2.png differ diff --git a/emoji-uploads/wrank_down_3.png b/emoji-uploads/wrank_down_3.png new file mode 100644 index 0000000..3898499 Binary files /dev/null and b/emoji-uploads/wrank_down_3.png differ diff --git a/emoji-uploads/wrank_down_4.png b/emoji-uploads/wrank_down_4.png new file mode 100644 index 0000000..f911e52 Binary files /dev/null and b/emoji-uploads/wrank_down_4.png differ diff --git a/emoji-uploads/wrank_down_5.png b/emoji-uploads/wrank_down_5.png new file mode 100644 index 0000000..0a4d8f5 Binary files /dev/null and b/emoji-uploads/wrank_down_5.png differ diff --git a/emoji-uploads/wrank_up.png b/emoji-uploads/wrank_up.png new file mode 100644 index 0000000..bf95c05 Binary files /dev/null and b/emoji-uploads/wrank_up.png differ diff --git a/emoji-uploads/wrank_up_1.png b/emoji-uploads/wrank_up_1.png new file mode 100644 index 0000000..b8d1247 Binary files /dev/null and b/emoji-uploads/wrank_up_1.png differ diff --git a/emoji-uploads/wrank_up_2.png b/emoji-uploads/wrank_up_2.png new file mode 100644 index 0000000..441d61b Binary files /dev/null and b/emoji-uploads/wrank_up_2.png differ diff --git a/emoji-uploads/wrank_up_3.png b/emoji-uploads/wrank_up_3.png new file mode 100644 index 0000000..eaed3f7 Binary files /dev/null and b/emoji-uploads/wrank_up_3.png differ diff --git a/emoji-uploads/wrank_up_4.png b/emoji-uploads/wrank_up_4.png new file mode 100644 index 0000000..0a9d7e9 Binary files /dev/null and b/emoji-uploads/wrank_up_4.png differ diff --git a/emoji-uploads/wrank_up_5.png b/emoji-uploads/wrank_up_5.png new file mode 100644 index 0000000..ccbb133 Binary files /dev/null and b/emoji-uploads/wrank_up_5.png differ diff --git a/messages/emojis.json b/messages/emojis.json index dc97d6a..5f14e99 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -1,43 +1,41 @@ { - "capella": "<:capella:1511020911027814453>", - "procyon": "<:procyon:1511020943323955301>", - "bl": "<:bl:1511014332685881364>", - "fb": "<:fb:1511020923510194428>", - "fs": "<:fs:1511020931542417459>", - "fa": "<:fa:1511020918929887434>", - "fg": "<:fg:1511020927461097482>", - "gl": "<:gl:1511020935463833711>", - "dm": "<:dm:1511020914974658612>", - "wi": "<:wi:1511020959706910913>", - "wa": "<:wa:1511020955231715448>", - "wrank_up": "", - "wrank_down": "", - "atk": "", - "def": "", - "heal": "", - "wrank_neutral": "", - "wrank_1": "", - "wrank_1_gold": "", - "wrank_2": "", - "wrank_2_gold": "", - "wrank_3": "", - "wrank_3_gold": "", - "wrank_4": "", - "wrank_4_gold": "", - "wrank_5": "", - "wrank_5_gold": "", - "wrank_6": "", - "wrank_6_gold": "", - "wrank_7": "", - "wrank_7_gold": "", - "wrank_8": "", - "wrank_8_gold": "", - "wrank_9": "", - "wrank_9_gold": "", - "wrank_10": "", - "wrank_10_gold": "", - "kd": "<:kd:1511020939226124339>", - "score": "<:score:1511020950718513172>", - "rank": "<:rank:1511020947019137187>", - "borrowed": "<:borrowed:1511020906754085057>" + "bl": "<:bl:1511906439516651561>", + "borrowed": "<:borrowed:1511906443245391944>", + "capella": "<:capella:1511906447167062137>", + "dm": "<:dm:1511906450866180126>", + "fa": "<:fa:1511906454506967242>", + "fb": "<:fb:1511906458231377950>", + "fg": "<:fg:1511906461977022605>", + "fs": "<:fs:1511906465798029423>", + "gl": "<:gl:1511906470684524594>", + "kd": "<:kd:1511906474497146983>", + "luminous_bringer": "<:luminous_bringer:1511906480184492263>", + "procyon": "<:procyon:1511906483993055295>", + "rank": "<:rank:1511906488380293180>", + "score": "<:score:1511906491903250525>", + "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_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>" } \ No newline at end of file diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts index b658158..80ea997 100644 --- a/scripts/upload-emojis.ts +++ b/scripts/upload-emojis.ts @@ -1,31 +1,42 @@ /** * Bulk emoji upload script - * Usage: npx ts-node scripts/upload-emojis.ts [emoji_dir] - * - * Place emoji PNG files in a directory named after the emoji key. - * Example: fb.png, wi.png, capella.png, wrank_1.png, wrank_1_gold.png + * Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] * + * 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. + * + * Required .env vars: + * DISCORD_TOKEN — bot token + * EMOJI_DONOR_GUILDS — comma-separated donor server IDs + * e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333 */ import { REST, Routes } from "discord.js"; import fs from "fs"; import path from "path"; - // Load .env manually since we're outside the bot + // Load .env manually since we're outside the bot runtime const envPath = path.join(__dirname, "../.env"); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { const [key, ...rest] = line.split("="); - if (key && rest.length) process.env[key.trim()] = rest.join("=").trim(); + if (key?.trim() && rest.length) process.env[key.trim()] = rest.join("=").trim(); } } - const TOKEN = process.env.DISCORD_TOKEN!; - const GUILD_ID = process.env.GUILD_ID!; + const TOKEN = process.env.DISCORD_TOKEN!; + const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); - if (!TOKEN || !GUILD_ID) { - console.error("❌ DISCORD_TOKEN and GUILD_ID must be set in .env"); + 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)"); process.exit(1); } @@ -40,6 +51,48 @@ const rest = new REST({ version: "10" }).setToken(TOKEN); + interface GuildEmojiSlot { + guildId: string; + name: string; // guild name for display + 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; + } + } + + async function fetchGuildSlots(guildIds: string[]): Promise { + const slots: GuildEmojiSlot[] = []; + + for (const guildId of guildIds) { + 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 }); + } 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()) @@ -58,42 +111,85 @@ console.warn("⚠️ Could not load emojis.json — will create fresh mapping."); } - console.log(`📁 Found ${files.length} file(s) in ${emojiDir}\n`); + console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`); + console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`); - // Fetch existing guild emojis to skip duplicates - const existing = await rest.get(Routes.guildEmojis(GUILD_ID)) as any[]; - const existingMap = new Map(existing.map((e: any) => [e.name, e.id])); + 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) { + 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`); + + 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 { + const start = slotIndex; + do { + const slot = guildSlots[slotIndex % guildSlots.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; + } + 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"; - if (existingMap.has(emojiName)) { - const formatted = `<:${emojiName}:${existingMap.get(emojiName)}>`; - emojiMap[emojiName] = formatted; - console.log(`⏭️ Already exists: ${emojiName} → ${formatted}`); + // 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]}`); skipped++; continue; } + const slot = nextAvailableSlot(); + if (!slot) { + console.error(`❌ All slots full — could not upload: ${emojiName}`); + console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`); + failed++; + continue; + } + try { - const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; - const result = await rest.post(Routes.guildEmojis(GUILD_ID), { + const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; + const result = await rest.post(Routes.guildEmojis(slot.guildId), { body: { name: emojiName, image: base64 }, }) as any; const formatted = `<:${emojiName}:${result.id}>`; emojiMap[emojiName] = formatted; - console.log(`✅ Uploaded: ${emojiName} → ${formatted}`); + slot.capacity--; + + console.log(`✅ Uploaded: ${emojiName} → ${formatted} [${slot.name}]`); uploaded++; - // Avoid rate limiting + // Rate limit buffer await new Promise((r) => setTimeout(r, 600)); } catch (err: any) { console.error(`❌ Failed: ${emojiName} — ${err.message}`); @@ -105,6 +201,11 @@ 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 diff --git a/src/commands/tg.ts b/src/commands/tg.ts index 67e60ad..b5f193b 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -69,6 +69,7 @@ export function buildTgCommand(): SlashCommandBuilder { ) .addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll") .addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)) + .addBooleanOption((o) => o.setName("simulate_close").setDescription("Simulate poll lock").setRequired(false)) ) .addSubcommand((s) => s.setName("unlock").setDescription("Unlock the active poll")) .addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening") @@ -77,7 +78,22 @@ export function buildTgCommand(): SlashCommandBuilder { .addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false)) .addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").setRequired(false)) ) - .addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk")) + .addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk") + .addStringOption(opt => + opt.setName("target") + .setDescription("What to reload") + .setRequired(false) + .addChoices( + { name: "All", value: "all" }, + { name: "Config", value: "config" }, + { name: "Messages", value: "messages" }, + { name: "Emojis", value: "emojis" }, + { name: "Characters", value: "characters" }, + { name: "W.Rank", value: "wrank" }, + { name: "Poll", value: "poll" }, + ) + ) + ) .addSubcommand((s) => s.setName("status").setDescription("Show current poll and config status")) .addSubcommand((s) => s.setName("set-message").setDescription("Set public message override for a user") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index 9d607c9..dc384d4 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -1,15 +1,23 @@ -import { ButtonInteraction, TextChannel } from "discord.js"; +import { + ButtonInteraction, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ActionRowBuilder, + TextChannel +} from "discord.js"; import { cfg } from "@systems/config"; import { pollReplyAndDelete } from "../utils"; import { resolveUser } from "@systems/users"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { resolveNation } from "@systems/nations"; import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll"; +import { persist } from "@systems/pollPersistence" import { showConflictEmbed } from "@systems/conflict"; import { getCharacters } from "@systems/characters"; import { getImpersonation } from "@systems/impersonate"; import { format } from "@format"; import { Character } from "@src/types"; +import { modals } from "@handlers/modals"; const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); @@ -63,10 +71,21 @@ async function handleCharacterConflict( const slot = [...polls.keys()][0]; const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; + // await interaction.followUp({ + // content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + // // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, + // ephemeral: true + // }); + const { buildCharSelectButtons } = require("@systems/charSelect"); + const buttons = buildCharSelectButtons(userKey ?? "", { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: char.name, + appendToCustomId: ":yes", + }); await interaction.followUp({ - content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, - // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, - ephemeral: true + content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + components: buttons, + ephemeral: true, }); return true; } @@ -181,6 +200,7 @@ export async function handleButton(interaction: ButtonInteraction): Promise= LOCK_AT; if (locked) state.locked = true; + persist.save(polls); const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; const msgContent = ephemeralMsg @@ -194,4 +214,71 @@ export async function handleButton(interaction: ButtonInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const user = await resolveUser(member); + if (!user.userKey) { + await interaction.reply({ content: "❌ You are not registered in the system.", ephemeral: true }); + return; + } + + const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; + const state = slot !== undefined ? polls.get(slot) : null; + + if (!state?.lockedYesKeys?.has(user.userKey)) { + await interaction.reply({ content: "❌ You weren't in this TG.", ephemeral: true }); + return; + } + + // Slot is known from the poll — go straight to modal, no select needed + await interaction.showModal(modals.buildScoreModal(user.userKey, slot!)); +} + +// export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise { +// await interaction.deferReply({ ephemeral: true }); + +// const member = await interaction.guild!.members.fetch(interaction.user.id); +// const user = await resolveUser(member); +// if (!user.userKey) { +// await interaction.editReply("❌ You are not registered in the system."); +// return; +// } + +// // Find the poll this message belongs to +// const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; +// const state = slot !== undefined ? polls.get(slot) : null; + +// // Enforce: only players who were locked in at TG start can submit +// if (!state?.lockedYesKeys?.has(user.userKey)) { +// await interaction.editReply("❌ You weren't in this TG."); +// return; +// } + +// // Build slot selector — all valid slots, with the active TG pre-selected +// const validSlots = cfg("slots").map((s) => s.tgHour) as number[]; +// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + +// const select = new StringSelectMenuBuilder() +// .setCustomId(`score_slot_select:${user.userKey}`) +// .setPlaceholder("Select TG slot") +// .addOptions( +// validSlots.map((h) => +// new StringSelectMenuOptionBuilder() +// .setLabel(`${String(h).padStart(2, "0")}:00 TG`) +// .setValue(String(h)) +// .setDefault(h === activeSlot) +// ) +// ); + +// const row = new ActionRowBuilder().addComponents(select); + +// await interaction.editReply({ +// content: "Which TG are you submitting for?", +// components: [row], +// }); +// } \ No newline at end of file diff --git a/src/handlers/interactions.ts b/src/handlers/interactions.ts index ee01719..c71967c 100644 --- a/src/handlers/interactions.ts +++ b/src/handlers/interactions.ts @@ -1,5 +1,5 @@ -import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; -import { handleButton } from "@handlers/buttons"; +import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel, StringSelectMenuInteraction } from "discord.js"; +import { handleButton, handleScoreSubmitButton } from "@handlers/buttons"; import { handleTgCommand } from "@commands/tg"; import { handleTgConfigCommand } from "@commands/tgConfig"; import { handleBorrowAcceptButton } from "@subcommands/char/accept"; @@ -13,6 +13,7 @@ import { polls, updatePollMessage } from "@systems/poll"; import { cfg } from "@systems/config"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { format } from "@format"; +import { modals } from "@handlers/modals"; import fs from "fs"; import path from "path"; @@ -77,19 +78,27 @@ async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise { publicMessage: publicMsg ?? undefined, }; + // Find and reuse existing vote ID — avoids duplicate entries + let existingVoteId: string | null = null; for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { if (entry.userKey === userKey) { + if (!existingVoteId) existingVoteId = id; state.yes.delete(id); state.no.delete(id); } } + const voteId = existingVoteId ?? btn.user.id; if (prevVoteType === "yes") { - state.yes.set(`switch_reclaim:${userKey}`, voteEntry); + state.yes.set(voteId, voteEntry); } else { - state.no.set(`switch_reclaim:${userKey}`, voteEntry); + state.no.set(voteId, voteEntry); } + console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`); + console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`)); + console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`)); + const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot!); } @@ -112,7 +121,9 @@ export async function handleInteraction(interaction: Interaction): Promise if (interaction.isButton()) { const btn = interaction as ButtonInteraction; + console.log("[interactions] interaction btnId:", btn.customId); if (btn.customId.startsWith("conflict_")) { + console.log("[interactions] routing to conflict handler:", btn.customId); return await handleConflictButton(btn); } @@ -134,9 +145,27 @@ export async function handleInteraction(interaction: Interaction): Promise return await handleBorrowDeclineButton(btn, ownerKey, requesterKey); } + if (btn.customId === "tg_score_submit") { + return await handleScoreSubmitButton(btn); + } + return await handleButton(btn); } + if (interaction.isModalSubmit()) { + return await modals.handleModal(interaction); + } + + if (interaction.isStringSelectMenu()) { + const sel = interaction as StringSelectMenuInteraction; + if (sel.customId.startsWith("score_slot_select:")) { + const userKey = sel.customId.split(":")[1]; + const slot = parseInt(sel.values[0], 10); + await sel.showModal(modals.buildScoreModal(userKey, slot)); + return; + } + } + if (interaction.isChatInputCommand()) { const cmd = interaction as ChatInputCommandInteraction; if (cmd.commandName === "tg") await handleTgCommand(cmd); diff --git a/src/handlers/modals.ts b/src/handlers/modals.ts new file mode 100644 index 0000000..495e337 --- /dev/null +++ b/src/handlers/modals.ts @@ -0,0 +1,156 @@ +import { + ModalSubmitInteraction, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} from "discord.js"; +import { resolveUser } from "@systems/users"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { score } from "@subcommands/score/submitCore"; +import { format } from "@format"; + +// ─── Modal IDs ──────────────────────────────────────────────────────────────── +// +// score_submit: — score submission modal, slot baked into the customId +// so we don't need to pass state through the modal itself + +export namespace modals { + + // ─── Builder ─────────────────────────────────────────────────────────────── + + /** + * Build the score submission modal for a given userKey + slot. + * Title shows the active character so the user knows what they're submitting for. + */ + export function buildScoreModal(userKey: string, slot: number): ModalBuilder { + const { char } = getEffectiveCharacter(userKey); + // const charLabel = char ? format.char(char) : "your character"; + const charLabel = char ? format.char(char, { emoji: false }) : "your character"; + + const modal = new ModalBuilder() + .setCustomId(`score_submit:${slot}`) + .setTitle(`Score for ${charLabel} — ${String(slot).padStart(2, "0")}:00`); + + const ptsInput = new TextInputBuilder() + .setCustomId("pts") + .setLabel("Points") + .setStyle(TextInputStyle.Short) + .setPlaceholder("e.g. 3000") + .setRequired(true); + + const kdInput = new TextInputBuilder() + .setCustomId("kd") + .setLabel("Kills / Deaths (e.g. 5/2)") + .setStyle(TextInputStyle.Short) + .setPlaceholder("5/2") + .setRequired(false); + + const atkDefInput = new TextInputBuilder() + .setCustomId("atkdef") + .setLabel("ATK / DEF (e.g. 120/80)") + .setStyle(TextInputStyle.Short) + .setPlaceholder("120/80") + .setRequired(false); + + const healInput = new TextInputBuilder() + .setCustomId("heal") + .setLabel("Heal") + .setStyle(TextInputStyle.Short) + .setPlaceholder("e.g. 500") + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(ptsInput), + new ActionRowBuilder().addComponents(kdInput), + new ActionRowBuilder().addComponents(atkDefInput), + new ActionRowBuilder().addComponents(healInput), + ); + + return modal; + } + + // ─── Parser helpers ──────────────────────────────────────────────────────── + + function parseSlashPair(raw: string | null): [number, number] | null { + if (!raw) return null; + const parts = raw.trim().split("/"); + if (parts.length !== 2) return null; + const a = parseInt(parts[0], 10); + const b = parseInt(parts[1], 10); + if (isNaN(a) || isNaN(b)) return null; + return [a, b]; + } + + function parseOptionalInt(raw: string | null): number | undefined { + if (!raw) return undefined; + const n = parseInt(raw.trim(), 10); + return isNaN(n) ? undefined : n; + } + + // ─── Handler ─────────────────────────────────────────────────────────────── + + export async function handleModal(interaction: ModalSubmitInteraction): Promise { + if (interaction.customId.startsWith("score_submit:")) { + await handleScoreSubmit(interaction); + return; + } + // Future modals routed here by customId prefix + } + + async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + + const slotStr = interaction.customId.split(":")[1]; + const slot = parseInt(slotStr, 10); + if (isNaN(slot)) { + await interaction.editReply("❌ Invalid slot in modal."); + return; + } + + const member = await interaction.guild!.members.fetch(interaction.user.id); + const user = await resolveUser(member); + if (!user.userKey) { + await interaction.editReply("❌ You are not registered in the system."); + return; + } + + // Parse fields + const ptsRaw = interaction.fields.getTextInputValue("pts"); + const kdRaw = interaction.fields.getTextInputValue("kd") || null; + const atkDefRaw = interaction.fields.getTextInputValue("atkdef") || null; + const healRaw = interaction.fields.getTextInputValue("heal") || null; + + const pts = parseInt(ptsRaw.trim(), 10); + if (isNaN(pts)) { + await interaction.editReply("❌ Points must be a number."); + return; + } + + const kd = parseSlashPair(kdRaw); + const atkDef = parseSlashPair(atkDefRaw); + const heal = parseOptionalInt(healRaw); + + if (kdRaw && !kd) { + await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`."); + return; + } + if (atkDefRaw && !atkDef) { + await interaction.editReply("❌ ATK/DEF must be in `atk/def` format, e.g. `120/80`."); + return; + } + + const result = await score.submitForUser({ + userKey: user.userKey, + pts, + slot, + k: kd?.[0], + d: kd?.[1], + atk: atkDef?.[0], + def: atkDef?.[1], + heal, + }); + + await interaction.editReply(result.message); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fbae39c..745a1bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,16 @@ -import { Client, GatewayIntentBits, REST, Routes } from "discord.js"; -import { loadConfig, cfg } from "./systems/config"; -import { loadMessages } from "./systems/messages"; -import { loadEmojis } from "./systems/emojis"; -import { loadCharacters } from "./systems/characters"; -import { loadWRank } from "./systems/wrank"; -import { scheduleSlots } from "./systems/slots"; -import { postPoll, polls } from "./systems/poll"; -import { handleInteraction } from "./handlers/interactions"; -import { buildTgCommand } from "./commands/tg"; -import { buildTgConfigCommand } from "./commands/tgConfig"; -import { TGSlot } from "./types"; +import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; +import { loadConfig, cfg } from "@systems/config"; +import { loadMessages } from "@systems/messages"; +import { loadEmojis } from "@systems/emojis"; +import { loadCharacters } from "@systems/characters"; +import { loadWRank } from "@systems/wrank"; +import { scheduleSlots } from "@systems/slots"; +import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; +import { handleInteraction } from "@handlers/interactions"; +import { buildTgCommand } from "@commands/tg"; +import { buildTgConfigCommand } from "@commands/tgConfig"; +import { TGSlot } from "@src/types"; +import { persist } from "@systems/pollPersistence" const TOKEN = process.env.DISCORD_TOKEN!; const CLIENT_ID = process.env.CLIENT_ID!; @@ -28,21 +29,35 @@ async function registerCommands(): Promise { } async function onPollOpen(slot: TGSlot): Promise { - const channelId = cfg("pollChannelId"); - const channel = await client.channels.fetch(channelId) as any; + const channel = await client.channels.fetch(cfg("pollChannelId")) as any; if (!channel) return console.error("Poll channel not found."); await postPoll(channel, slot); } +// Fires at tgHour exactly (e.g. 20:00) — voting closes, lockedYesKeys snapshotted +async function onPollLock(slot: TGSlot): Promise { + const state = polls.get(slot.tgHour); + if (!state || state.locked) return; + + lockPoll(slot.tgHour); + + const channel = await client.channels.fetch(cfg("pollChannelId")) as any; + if (!channel) return; + + // Buttons disabled, no submit button yet — that comes at close + await updatePollMessage(channel, slot.tgHour); + console.log(`[${new Date().toISOString()}] Poll locked for ${slot.tgHour}:00.`); +} + +// Fires at tgHour + closesAfter (e.g. 20:35) — TG ended, reveal Submit Score async function onPollClose(slot: TGSlot): Promise { const state = polls.get(slot.tgHour); if (!state) return; - state.locked = true; - const channelId = cfg("pollChannelId"); - const channel = await client.channels.fetch(channelId) as any; + + const channel = await client.channels.fetch(cfg("pollChannelId")) as any; if (!channel) return; - const { updatePollMessage } = require("./systems/poll"); - await updatePollMessage(channel, slot.tgHour); + + await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`); } @@ -51,27 +66,36 @@ client.on("interactionCreate", handleInteraction); client.once("clientReady", async () => { console.log(`Logged in as ${client.user!.tag}`); - // Load all data loadConfig(); loadMessages(); loadEmojis(); loadCharacters(); loadWRank(); - // Warm member cache + const restored = persist.load(); + if (restored) { + for (const [slot, state] of restored) polls.set(slot, state); + + // Re-render all restored poll messages + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + for (const slot of polls.keys()) { + const state = polls.get(slot)!; + await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null); + } + console.log("Poll state restored and messages re-rendered."); + } + const guild = await client.guilds.fetch(GUILD_ID); await guild.members.fetch(); console.log(`Member cache warmed: ${guild.members.cache.size} members`); - // Register commands if --register flag passed if (process.argv.includes("--register")) { await registerCommands(); } - // Schedule slots - scheduleSlots(client, onPollOpen, onPollClose); + scheduleSlots(client, onPollOpen, onPollLock, onPollClose); console.log("Bot ready."); }); -client.login(TOKEN); +client.login(TOKEN); \ No newline at end of file diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..b1fdcef --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,33 @@ +import cron from "node-cron"; +import { TextChannel } from "discord.js"; + +// Lock poll at TG start (20:00), reveal Submit Score button at TG end (20:35) +// Runs daily — no-ops silently if no poll is active for that slot. + +cron.schedule("0 20 * * *", async () => { + const slot = 20; + const state = polls.get(slot); + if (!state || state.locked) return; + + lockPoll(slot); + + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot, cfg("lockMessage")); + console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`); +}); + +cron.schedule("35 20 * * *", async () => { + const slot = 20; + const state = polls.get(slot); + if (!state) return; + + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot, undefined, true); // showSubmit = true + console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`); +}); + +// ─── NOTE on future slots ───────────────────────────────────────────────────── +// +// Right now only slot 20 has an active poll. When we add more votable slots, +// pull the active slot from cfg("slots").filter(s => s.active) and schedule +// dynamically, or make the cron time configurable in config.json. \ No newline at end of file diff --git a/src/subcommands/poll/lock.ts b/src/subcommands/poll/lock.ts index a92b03a..cff9ed6 100644 --- a/src/subcommands/poll/lock.ts +++ b/src/subcommands/poll/lock.ts @@ -1,21 +1,30 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; -import { polls, updatePollMessage } from "../../systems/poll"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { polls, lockPoll, updatePollMessage } from "@systems/poll"; +import { replyAndDelete } from "@utils"; export async function handleLock(interaction: ChatInputCommandInteraction): Promise { - const oneTimeMsg = interaction.options.getString("message") ?? undefined; - const slot = getActiveSlot(); - if (!slot) return void replyAndDelete(interaction, "❌ No active poll found."); + const oneTimeMsg = interaction.options.getString("message") ?? undefined; + const simulateClose = interaction.options.getBoolean("simulate_close") ?? false; + const slot = getActiveSlot(); + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); - const state = polls.get(slot)!; - state.locked = true; + // Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron + lockPoll(slot); const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + + if (simulateClose) { + // Simulate TG end: show Submit Score button (same path as onPollClose cron) + await updatePollMessage(channel, slot, oneTimeMsg, true); + return void replyAndDelete(interaction, "🔒 Poll locked + 📊 Submit Score button shown (simulate close)."); + } + + // Normal lock: voting closes, no submit button yet await updatePollMessage(channel, slot, oneTimeMsg); return void replyAndDelete(interaction, "🔒 Poll locked."); } function getActiveSlot(): number | undefined { return [...polls.keys()][0]; -} +} \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts b/src/subcommands/poll/reload.ts index b332963..b90f629 100644 --- a/src/subcommands/poll/reload.ts +++ b/src/subcommands/poll/reload.ts @@ -1,12 +1,88 @@ -import { ChatInputCommandInteraction } from "discord.js"; -import { loadMessages } from "../../systems/messages"; -import { loadEmojis } from "../../systems/emojis"; -import { loadCharacters } from "../../systems/characters"; -import { replyAndDelete } from "../../utils"; +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { loadMessages } from "@systems/messages"; +import { loadEmojis } from "@systems/emojis"; +import { loadCharacters } from "@systems/characters"; +import { loadWRank } from "@systems/wrank"; +import { loadConfig, cfg } from "@systems/config"; +import { polls, updatePollMessage } from "@systems/poll"; +import { persist } from "@systems/pollPersistence"; +import { replyAndDelete } from "@utils"; + +const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const; +type Reloadable = typeof RELOADABLE[number]; export async function handleReload(interaction: ChatInputCommandInteraction): Promise { - loadMessages(); // reloads global.json, usermap.json, users/*.json - loadEmojis(); // reloads emojis.json - loadCharacters(); // reloads characters.json and accounts.json - return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk."); -} \ No newline at end of file + const target = (interaction.options.getString("target") ?? "all") as Reloadable; + + const reloaded: string[] = []; + + const should = (k: Reloadable) => target === "all" || target === k; + + if (should("config")) { loadConfig(); reloaded.push("config"); } + if (should("messages")) { loadMessages(); reloaded.push("messages"); } + if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); } + if (should("characters")) { loadCharacters(); reloaded.push("characters"); } + if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } + + // Re-render active poll message(s) so embed reflects reloaded data + if (should("poll") || should("emojis") || should("all")) { + const channelId = cfg("pollChannelId"); + if (channelId) { + try { + // Restore from disk first if reloading poll specifically + if (should("poll")) { + const restored = persist.load(); + if (restored) { + polls.clear(); + for (const [slot, state] of restored) polls.set(slot, state); + } + } + if (polls.size > 0) { + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + for (const slot of polls.keys()) { + const state = polls.get(slot)!; + const showSubmit = state.locked && state.confirmed === null; + await updatePollMessage(channel, slot, undefined, showSubmit); + } + reloaded.push("poll message"); + } + } catch (err) { + console.error("Failed to re-render poll message on reload:", err); + } + } + } + + return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`); +} + +// export async function handleReload(interaction: ChatInputCommandInteraction): Promise { +// const target = (interaction.options.getString("target") ?? "all") as Reloadable; + +// const reloaded: string[] = []; + +// const should = (k: Reloadable) => target === "all" || target === k; + +// if (should("config")) { loadConfig(); reloaded.push("config"); } +// if (should("messages")) { loadMessages(); reloaded.push("messages"); } +// if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); } +// if (should("characters")) { loadCharacters(); reloaded.push("characters"); } +// if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } + +// // Re-render active poll message(s) so embed reflects reloaded data +// if (should("poll") || should("emojis") || should("all")) { +// const channelId = cfg("pollChannelId"); +// if (channelId && polls.size > 0) { +// try { +// const channel = await interaction.client.channels.fetch(channelId) as TextChannel; +// for (const slot of polls.keys()) { +// await updatePollMessage(channel, slot); +// } +// reloaded.push("poll message"); +// } catch (err) { +// console.error("Failed to re-render poll message on reload:", err); +// } +// } +// } + +// return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`); +// } \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts.bak b/src/subcommands/poll/reload.ts.bak new file mode 100644 index 0000000..b332963 --- /dev/null +++ b/src/subcommands/poll/reload.ts.bak @@ -0,0 +1,12 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { loadMessages } from "../../systems/messages"; +import { loadEmojis } from "../../systems/emojis"; +import { loadCharacters } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; + +export async function handleReload(interaction: ChatInputCommandInteraction): Promise { + loadMessages(); // reloads global.json, usermap.json, users/*.json + loadEmojis(); // reloads emojis.json + loadCharacters(); // reloads characters.json and accounts.json + return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk."); +} \ No newline at end of file diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index c1050be..8981b3c 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -1,7 +1,9 @@ import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; -import { cfg } from "../../systems/config"; -import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { getCurrentWeek, getWeekKey, getBringer } from "@systems/wrank"; +import { getEmoji } from "@systems/emojis"; +import { replyAndDelete } from "@utils"; +import { format } from "@src/systems/format"; export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { const week = getCurrentWeek(); @@ -11,29 +13,53 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): const formatNation = (nation: "capella" | "procyon"): string => { const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); if (entries.length === 0) return "—"; + const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon"); + return entries.map((e) => { - const isDone = e.tgCount >= goal; - const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0; - const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : ""; + const isDone = e.tgCount >= goal; + + // ── Character indicator ─────────────────────────────────────────────────── + const charStr = format.char({ class: e.class, level: 79, name: e.characterName }); + + // ── Rank indicator ─────────────────────────────────────────────────── + const rankStr = format.wrank.rank(e, goal); + const deltaStr = format.wrank.delta(e, { brackets: false }); + + // ── Bringer label ──────────────────────────────────────────────────── const bringerStr = bringer === e.userKey && isDone ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""; - return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; - }).join("\n"); + + // ── Score indicator ─────────────────────────────────────────────────── + const scoreStr = format.score(e.weeklyPoints); + + return `${rankStr}(${deltaStr}) ${charStr} — ${scoreStr} ${bringerStr}`; + }).join("\n"); + // return `${rankStr}(${deltaStr}) ${e.characterName} — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; + // }).join("\n"); }; const embed = new EmbedBuilder() - .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`) + .setTitle(`⚔️ TG Leaderboard — ${weekKey}`) .setColor(0xe8a317) .addFields( - { name: "🔵 Capella", value: formatNation("capella"), inline: true }, - { name: "🔴 Procyon", value: formatNation("procyon"), inline: true }, + { name: format.nation("Capella"), value: formatNation("capella"), inline: true }, + { name: format.nation("Procyon"), value: formatNation("procyon"), inline: true }, ) .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] }); return void replyAndDelete(interaction, "✅ Leaderboard posted."); -} +} \ No newline at end of file diff --git a/src/subcommands/rank/post.ts.bak b/src/subcommands/rank/post.ts.bak new file mode 100644 index 0000000..c1050be --- /dev/null +++ b/src/subcommands/rank/post.ts.bak @@ -0,0 +1,39 @@ +import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; +import { cfg } from "../../systems/config"; +import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; +import { replyAndDelete } from "../../utils"; + +export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { + const week = getCurrentWeek(); + const goal = cfg("wRankGoal"); + const weekKey = getWeekKey(); + + const formatNation = (nation: "capella" | "procyon"): string => { + const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); + if (entries.length === 0) return "—"; + const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon"); + return entries.map((e) => { + const isDone = e.tgCount >= goal; + const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0; + const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : ""; + const bringerStr = bringer === e.userKey && isDone + ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` + : ""; + return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; + }).join("\n"); + }; + + 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] }); + return void replyAndDelete(interaction, "✅ Leaderboard posted."); +} diff --git a/src/subcommands/score/submitCore.ts b/src/subcommands/score/submitCore.ts new file mode 100644 index 0000000..78f6d21 --- /dev/null +++ b/src/subcommands/score/submitCore.ts @@ -0,0 +1,86 @@ +import { cfg } from "@systems/config"; +import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { format } from "@format"; +import { getEmoji } from "@systems/emojis"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ScoreSubmitInput { + userKey: string; + pts: number; + slot?: number | string | null; // number = already resolved, string = needs normalizing, null/undefined = auto-detect + k?: number; + d?: number; + atk?: number; + def?: number; + heal?: number; + submittedByOfficer?: boolean; +} + +export type ScoreSubmitResult = + | { ok: true; message: string } + | { ok: false; message: string }; + +// ─── Core ───────────────────────────────────────────────────────────────────── + +export namespace score { + /** + * Resolve, validate and persist a score submission for a given userKey. + * Used by both the slash command handler and the modal submit handler. + */ + export async function submitForUser(input: ScoreSubmitInput): Promise { + const { userKey, pts, k, d, atk, def, heal, submittedByOfficer = false } = input; + + const { char, borrowedFrom } = getEffectiveCharacter(userKey); + if (!char) { + return { ok: false, message: "❌ No active character found. Use `/tg char set-active` first." }; + } + + // Resolve slot + let slot: number | null = null; + if (typeof input.slot === "number") { + slot = input.slot; + } else if (typeof input.slot === "string") { + slot = normalizeSlot(input.slot); + if (slot === null) { + return { ok: false, message: `❌ Could not parse slot "${input.slot}".` }; + } + } else { + slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + } + + await submitScore({ + userKey: borrowedFrom ?? userKey, + playedBy: borrowedFrom ? userKey : undefined, + characterName: char.name, + cls: char.class, + nation: char.nation, + pts, + k, + d, + slot, + atk, + def, + heal, + submittedByOfficer, + }); + + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; + const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : ""; + const statsNote = [ + atk !== undefined ? `ATK: ${atk}` : null, + def !== undefined ? `DEF: ${def}` : null, + heal !== undefined ? `HEAL: ${heal}` : null, + ].filter(Boolean).join(" · "); + + const charDisplay = format.char(char); + return { + ok: true, + message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + // message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + }; + } +} \ No newline at end of file diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts index 43a6de6..82fdb2b 100644 --- a/src/subcommands/switch.ts +++ b/src/subcommands/switch.ts @@ -13,6 +13,7 @@ import { polls, updatePollMessage } from "@systems/poll"; import { getClassEmoji } from "@systems/emojis"; import { replyAndDelete } from "@src/utils"; import { format } from "@format"; +import { buildCharSelectButtons } from "@systems/charSelect"; import fs from "fs"; import path from "path"; @@ -92,15 +93,28 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr const charDisplay = format.char(resolvedChar); const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name); if (isOwner) { - return void replyAndDelete(interaction, - `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character and use the reclaim button that appears.`, - true - ); + await interaction.reply({ + content: `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to a different one:`, + components: buildCharSelectButtons(userKey, { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: resolvedChar.name, + appendToCustomId: ":yes", + }), + ephemeral: true, + }); + return; } - return void replyAndDelete(interaction, - `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, - true - ); + const buttons = buildCharSelectButtons(userKey, { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: resolvedChar.name, + appendToCustomId: `:${"yes"}`, + }); + await interaction.reply({ + content: `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + components: buttons, + ephemeral: true, + }); + return; } } } diff --git a/src/systems/charSelect.ts b/src/systems/charSelect.ts new file mode 100644 index 0000000..63ffe9a --- /dev/null +++ b/src/systems/charSelect.ts @@ -0,0 +1,100 @@ +import { + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, +} from "discord.js"; +import { getCharacters, getCharacterByName } from "@systems/characters"; +import { getClassEmoji } from "@systems/emojis"; +import { format } from "@format"; +import { Character } from "@types"; +import fs from "fs"; +import path from "path"; + +const CHARS_PATH = path.join(__dirname, "../../data/characters.json"); + +export interface CharSelectOptions { + customIdPrefix: string; // e.g. "switch_after_reclaim:flash" + excludeCharName?: string; // exclude this char from the list + appendToCustomId?: string; // appended after charName e.g. ":yes" + pageSize?: number; // default 4 + page?: number; // default 0 +} + +/** + * Builds paginated character selection button rows for a given user. + * Includes own characters + shared characters. + * Returns up to 2 rows: one for char buttons, one for pagination if needed. + */ +export function buildCharSelectButtons( + userKey: string, + options: CharSelectOptions +): ActionRowBuilder[] { + const { + customIdPrefix, + excludeCharName, + appendToCustomId = "", + pageSize = 4, + page = 0, + } = options; + + // Gather own + shared chars + const ownChars = getCharacters(userKey); + + const sharedChars: Character[] = []; + try { + const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); + for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { + if (ownerKey === userKey) continue; + for (const c of data.characters ?? []) { + if (c.sharedWith?.includes(userKey)) sharedChars.push(c); + } + } + } catch {} + + const allChars = [...ownChars, ...sharedChars] + .filter((c) => c.name !== excludeCharName); + + const pageChars = allChars.slice(page * pageSize, (page + 1) * pageSize); + const hasNext = allChars.length > (page + 1) * pageSize; + const hasPrev = page > 0; + const rows: ActionRowBuilder[] = []; + + // Char buttons + if (pageChars.length > 0) { + const btns = pageChars.map((c) => { + const emojiStr = getClassEmoji(c.class); + const emoji = format.emoji(emojiStr); + const btn = new ButtonBuilder() + .setCustomId(`${customIdPrefix}:${c.name}${appendToCustomId}`) + .setStyle(ButtonStyle.Secondary) + .setLabel(`${c.level} ${c.name}`); + if (emoji) btn.setEmoji(emoji as any); + return btn; + }); + rows.push(new ActionRowBuilder().addComponents(...btns)); + } + + // Pagination row + const navBtns: ButtonBuilder[] = []; + if (hasPrev) { + navBtns.push( + new ButtonBuilder() + .setCustomId(`${customIdPrefix}_page:${page - 1}`) + .setLabel("← Prev") + .setStyle(ButtonStyle.Primary) + ); + } + if (hasNext) { + navBtns.push( + new ButtonBuilder() + .setCustomId(`${customIdPrefix}_page:${page + 1}`) + .setLabel("Next →") + .setStyle(ButtonStyle.Primary) + ); + } + if (navBtns.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...navBtns)); + } + + return rows; +} \ No newline at end of file diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts index 97f513b..608bee2 100644 --- a/src/systems/conflict.ts +++ b/src/systems/conflict.ts @@ -15,6 +15,8 @@ import { resolveMessage, nowFormatted } from "@systems/messages"; import { getClassEmoji } from "@systems/emojis"; import { format } from "@systems/format"; import { Character } from "@types"; +import { buildCharSelectButtons } from "@systems/charSelect"; + // ─── Config ─────────────────────────────────────────────────────────────────── const RECLAIM_STYLE = ButtonStyle.Secondary; @@ -122,6 +124,7 @@ export async function showConflictEmbed( } export async function handleConflictButton(interaction: ButtonInteraction): Promise { + console.log("[conflict] button received:", interaction.customId); const { customId } = interaction; // ── Pagination ────────────────────────────────────────────────────────────── @@ -196,6 +199,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom // ── Reclaim ───────────────────────────────────────────────────────────────── if (customId.startsWith("conflict_reclaim:")) { + console.log("[reclaim] handler triggered"); const parts = customId.split(":"); const ownerKey = parts[1]; const borrowerKey = parts[2]; @@ -266,30 +270,24 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot!); + console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER); + // Notify borrower if enabled and we have their Discord ID if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) { try { const borrowerMember = await guild.members.fetch(borrowerDiscordId); - const borrowerChars = getCharacters(borrowerKey); - if (borrowerChars.length > 0) { - const btns = borrowerChars.slice(0, 5).map((c) => - applyCharToButton( - new ButtonBuilder() - .setCustomId(`switch_after_reclaim:${borrowerKey}:${c.name}:${borrowerVoteType}`) - .setStyle(ButtonStyle.Secondary), - c - ) - ); - await borrowerMember.send({ - content: `⚠️ **${format.char({ class: char?.class ?? "FB" as any, level: char?.level ?? 0, name: charName })}** was reclaimed by **${ownerKey}**. Pick another character:`, - components: [new ActionRowBuilder().addComponents(...btns)], - }); - } else { - await borrowerMember.send({ - content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. You've been removed from the poll.`, - }); - } + const btns = buildCharSelectButtons(borrowerKey, { + customIdPrefix: `switch_after_reclaim:${borrowerKey}`, + excludeCharName: charName, + appendToCustomId: `:${borrowerVoteType}`, + }); + console.log("[reclaim notify] btns length:", btns.length); + console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON()))); + await borrowerMember.send({ + content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`, + components: btns.length > 0 ? btns : [], + }); } catch { // DM may be disabled — silently ignore } diff --git a/src/systems/format.ts b/src/systems/format.ts index 899e0b0..7205b43 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -1,4 +1,4 @@ -import { ClassKey, Nation } from "@src/types"; +import { ClassKey, Nation, WRankEntry } from "@src/types"; import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis"; // ─── Individual formatters ──────────────────────────────────────────────────── @@ -55,6 +55,57 @@ function emoji(emojiStr: string): { name: string; id: string } | string | null { return emojiStr; } +// ─── W.Rank formatters ──────────────────────────────────────────────────────── + +export interface WRankDisplayOptions { + goal: number; + brackets?: boolean; // wrap delta in parentheses (default: true) +} + +/** + * Format the rank indicator for a wrank entry. + * Output: <:wrank_1_gold:> or <:wrank_1:> or bold/plain number fallback + */ +function wrankRank(entry: WRankEntry, goal: number): string { + const isDone = entry.tgCount >= goal; + const rankKey = isDone ? `wrank_${entry.currentRank}_gold` : `wrank_${entry.currentRank}`; + return getEmoji(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`); +} + +/** + * Format the delta indicator for a wrank entry. + * Output: <:wrank_up:><:wrank_up_2:> or ↑2, empty string if no change + */ +function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean }): string { + const brackets = options?.brackets ?? true; + const prev = entry.previousRank; + const delta = prev !== undefined ? entry.currentRank - prev : 0; + + if (delta === 0 && prev === undefined) return ""; + + let inner: string; + if (delta < 0) { + const abs = Math.abs(delta); + const numEmoji = getEmoji(`wrank_up_${abs}`); + inner = (getEmoji("wrank_up") || "↑") + (numEmoji || abs); + } else if (delta > 0) { + const numEmoji = getEmoji(`wrank_down_${delta}`); + inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta); + } else { + inner = (getEmoji("wrank_neutral") || "·") + "0"; + } + + return brackets ? ` (${inner})` : ` ${inner}`; +} + +/** + * Format a full wrank display string: rank + delta. + * Output: <:wrank_1_gold:> (<:wrank_up:><:wrank_up_2:>) + */ +function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string { + return wrankRank(entry, options.goal) + wrankDelta(entry, { brackets: options.brackets }); +} + // ─── Namespace export ───────────────────────────────────────────────────────── export const format = { @@ -62,4 +113,9 @@ export const format = { nation, score, emoji, + wrank: { + rank: wrankRank, + delta: wrankDelta, + full: wrankFull, + }, }; \ No newline at end of file diff --git a/src/systems/poll.ts b/src/systems/poll.ts index 239f629..efab526 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -6,13 +6,17 @@ import { TextChannel, GuildMember, } from "discord.js"; -import { PollState, VoteEntry, Nation, TGSlot } from "../types"; -import { cfg } from "./config"; -import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis"; -import { getActiveCharacter, getCharacterByName } from "./characters"; -import { resolveNation } from "./nations"; -import { getEntry, getBringer } from "./wrank"; -import { nowFormatted } from "./messages"; +import { PollState, VoteEntry, Nation, TGSlot } from "@src/types"; +import { cfg } from "@systems/config"; +import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis"; +import { getActiveCharacter, getCharacterByName } from "@systems/characters"; +import { resolveNation } from "@systems/nations"; +import { getEntry, getBringer } from "@systems/wrank"; +import { nowFormatted } from "@systems/messages"; +import { format } from "@format"; +import { persist } from "@systems/pollPersistence" +import { clearSessionBorrows } from "@systems/borrow"; +import { clearAllImpersonations } from "@systems/impersonate"; // ─── Poll state ─────────────────────────────────────────────────────────────── export const polls: Map = new Map(); @@ -51,30 +55,32 @@ export function resetPollOverrides(): void { ephemeralOverrides.clear(); } +export function lockPoll(slot: number): void { + const state = polls.get(slot); + if (!state) return; + state.locked = true; + + // Snapshot the userKeys that were in yes at lock time + state.lockedYesKeys = new Set( + [...state.yes.values()] + .map((e) => e.userKey) + .filter((k): k is string => !!k) + ); + + persist.save(polls) +} + + // ─── Character display ──────────────────────────────────────────────────────── function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { - const format = cfg("charDisplayFormat"); + const cfgFormat = cfg("charDisplayFormat"); const nation = entry.characterNation; const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; let wrank = ""; if (wRankEntry) { - const goal = cfg("wRankGoal"); - const isDone = wRankEntry.tgCount >= goal; - const rank = wRankEntry.currentRank; - const prev = wRankEntry.previousRank; - const delta = prev !== undefined ? rank - prev : 0; - // W.Rank emoji with text fallback - const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`; - const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`); - - // Delta arrows with text fallback - let deltaStr = ""; - if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`; - else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`; - else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`; - - wrank = `${rankStr}${deltaStr}`; + const wRankGoal = cfg("wRankGoal"); + wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true }); } const classStr = entry.characterClass @@ -85,7 +91,7 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { ? `${entry.characterLevel}` : ""; - let row = format + let row = cfgFormat .replace("{wrank}", wrank) .replace("{class}", classStr) .replace("{level}", levelStr) @@ -94,15 +100,33 @@ 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 emoji = nation === "Capella" - ? (getEmoji("luminous_bringer") || "🔆") - : (getEmoji("storm_bringer") || "⚡"); - const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; - row += ` · ${emoji} **${title}**`; + const bringerTitle = getNationBringerTitle(nation); + row += ` · ${bringerTitle}`; } + // 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) { @@ -203,21 +227,41 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui return embed; } -export function buildButtons(disabled: boolean): ActionRowBuilder { +export function buildButtons( + disabled: boolean, + showSubmit?: boolean +): ActionRowBuilder[] { + if (showSubmit) { + const scoreEmoji = getEmoji("score"); + const submitBtn = new ButtonBuilder() + .setCustomId("tg_score_submit") + .setLabel("Submit Score") + .setStyle(ButtonStyle.Secondary); + if (scoreEmoji) submitBtn.setEmoji(format.emoji(scoreEmoji) ?? scoreEmoji); + return [new ActionRowBuilder().addComponents(submitBtn)]; + } + const yesBtn = new ButtonBuilder() .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled); const noBtn = new ButtonBuilder() .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled); - return new ActionRowBuilder().addComponents(yesBtn, noBtn); + return [new ActionRowBuilder().addComponents(yesBtn, noBtn)]; } -export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise { +export async function updatePollMessage( + channel: TextChannel, + slot: number, + overrideLockMsg?: string, + showSubmit?: boolean +): Promise { const state = polls.get(slot); if (!state?.messageId) return; + console.log(`[updatePollMessage] slot=${slot} showSubmit=${showSubmit} messageId=${state.messageId}`); + const buttons = buildButtons(state.locked || state.confirmed !== null, showSubmit); + console.log(`[updatePollMessage] components rows=${buttons.length}`); try { - const msg = await channel.messages.fetch(state.messageId); - const disabled = state.locked || state.confirmed !== null; - await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] }); + const msg = await channel.messages.fetch(state.messageId); + await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buttons }); } catch (err) { console.error("Failed to update poll message:", err); } @@ -225,8 +269,7 @@ export async function updatePollMessage(channel: TextChannel, slot: number, over export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { resetPollOverrides(); - const { clearSessionBorrows } = require("@systems/borrow"); - const { clearAllImpersonations } = require("@systems/impersonate"); + persist.clear(); clearSessionBorrows(); clearAllImpersonations(); @@ -237,9 +280,11 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise): SerializedPollState[] { + return [...polls.values()].map((s) => ({ + messageId: s.messageId, + slot: s.slot, + yes: [...s.yes.entries()], + no: [...s.no.entries()], + locked: s.locked, + confirmed: s.confirmed, + lockMessage: s.lockMessage, + confirmMessage: s.confirmMessage, + lockedYesKeys: s.lockedYesKeys ? [...s.lockedYesKeys] : undefined, + })); +} + +function deserialize(data: SerializedPollState[]): Map { + const polls = new Map(); + for (const s of data) { + polls.set(s.slot, { + messageId: s.messageId, + slot: s.slot, + yes: new Map(s.yes), + no: new Map(s.no), + locked: s.locked, + confirmed: s.confirmed, + lockMessage: s.lockMessage, + confirmMessage: s.confirmMessage, + lockedYesKeys: s.lockedYesKeys ? new Set(s.lockedYesKeys) : undefined, + }); + } + return polls; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export namespace persist { + export function save(polls: Map): void { + try { + fs.writeFileSync(PERSIST_PATH, JSON.stringify(serialize(polls), null, 2), "utf8"); + } catch (err) { + console.error("[pollPersistence] Failed to save poll state:", err); + } + } + + export function load(): Map | null { + try { + if (!fs.existsSync(PERSIST_PATH)) return null; + const raw = fs.readFileSync(PERSIST_PATH, "utf8"); + const data = JSON.parse(raw) as SerializedPollState[]; + const polls = deserialize(data); + console.log(`[pollPersistence] Restored ${polls.size} poll(s) from disk.`); + return polls; + } catch (err) { + console.error("[pollPersistence] Failed to load poll state:", err); + return null; + } + } + + export function clear(): void { + try { + if (fs.existsSync(PERSIST_PATH)) fs.unlinkSync(PERSIST_PATH); + } catch (err) { + console.error("[pollPersistence] Failed to clear poll state:", err); + } + } +} \ No newline at end of file diff --git a/src/systems/slots.ts b/src/systems/slots.ts index 00bf851..8eeb685 100644 --- a/src/systems/slots.ts +++ b/src/systems/slots.ts @@ -1,19 +1,21 @@ import cron from "node-cron"; -import { Client } from "discord.js"; +import { Client, TextChannel } from "discord.js"; import { cfg } from "./config"; import { TGSlot } from "../types"; +import { polls, updatePollMessage } from "@systems/poll"; -type PollCallback = (slot: TGSlot) => Promise; +type PollCallback = (slot: TGSlot) => Promise; type CloseCallback = (slot: TGSlot) => Promise; +type LockCallback = (slot: TGSlot) => Promise; let _scheduledTasks: cron.ScheduledTask[] = []; export function scheduleSlots( - client: Client, - onPollOpen: PollCallback, - onPollClose: CloseCallback + client: Client, + onPollOpen: PollCallback, + onPollLock: LockCallback, + onPollClose: CloseCallback, ): void { - // Clear existing schedules _scheduledTasks.forEach((t) => t.stop()); _scheduledTasks = []; @@ -21,37 +23,46 @@ export function scheduleSlots( const slots = cfg("slots").filter((s) => s.active); for (const slot of slots) { - // Parse poll open time + // Poll open const [openHour, openMin] = slot.pollOpens.split(":").map(Number); - - // Schedule poll open - const openTask = cron.schedule( + _scheduledTasks.push(cron.schedule( `${openMin} ${openHour} * * *`, () => onPollOpen(slot), { timezone: tz } - ); - _scheduledTasks.push(openTask); + )); - // Schedule poll close (tgHour + closesAfter minutes) + // Poll lock — exactly at tgHour (TG start, voting closes, lockedYesKeys snapshotted) + _scheduledTasks.push(cron.schedule( + `0 ${slot.tgHour} * * *`, + () => onPollLock(slot), + { timezone: tz } + )); + + // Poll close — tgHour + closesAfter minutes (TG end, Submit Score button appears) const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; const closeHour = Math.floor(closeMinTotal / 60) % 24; const closeMin = closeMinTotal % 60; - - const closeTask = cron.schedule( + _scheduledTasks.push(cron.schedule( `${closeMin} ${closeHour} * * *`, () => onPollClose(slot), { timezone: tz } - ); - _scheduledTasks.push(closeTask); + )); + + _scheduledTasks.push(cron.schedule("0 0 * * *", async () => { + const state = polls.get(slot.tgHour); + if (!state?.locked) return; // only if poll has been locked (TG happened) + const channel = await (client as any).channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot.tgHour, undefined, false); + console.log(`[${new Date().toISOString()}] Submit Score button removed.`); + }, { timezone: tz })); } // Weekly reset — Monday 00:00 - const resetTask = cron.schedule("0 0 * * 1", () => { + _scheduledTasks.push(cron.schedule("0 0 * * 1", () => { const { resetWeek } = require("./wrank"); resetWeek(); console.log("W.Rank weekly reset complete."); - }, { timezone: tz }); - _scheduledTasks.push(resetTask); + }, { timezone: tz })); console.log(`Scheduled ${slots.length} slot(s).`); -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 9f5b2e4..b33e6ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,6 +119,7 @@ export interface PollState { confirmed: "yes" | "no" | null; lockMessage?: string; confirmMessage?: string; + lockedYesKeys?: Set; // snapshot of userKeys in yes at lock time } // ─── Scores ──────────────────────────────────────────────────────────────────