feat: character sharing/borrowing, impersonation, conflict resolution, W.Rank per char, autocomplete, UI improvements
5
.env
|
|
@ -5,9 +5,12 @@ SCORE_CHANNEL_ID=1511006435079884991
|
||||||
CLIENT_ID=1510959814623105044
|
CLIENT_ID=1510959814623105044
|
||||||
GUILD_ID=1511006171681652858
|
GUILD_ID=1511006171681652858
|
||||||
EPHEMERAL_DELETE_MS=0
|
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)
|
COMMAND_EPHEMERAL_ENABLED=true # command outputs (always on)
|
||||||
AUTO_VOTE_ON_CONFLICT_SWITCH=true
|
AUTO_VOTE_ON_CONFLICT_SWITCH=true
|
||||||
IMPERSONATE_RESET_ON_POLL=false
|
IMPERSONATE_RESET_ON_POLL=false
|
||||||
IMPERSONATE_INDICATOR=true
|
IMPERSONATE_INDICATOR=true
|
||||||
RECLAIM_NOTIFY_BORROWER=true
|
RECLAIM_NOTIFY_BORROWER=true
|
||||||
|
|
||||||
|
# Emoji upload servers
|
||||||
|
EMOJI_DONOR_GUILDS=1511903882224336926,1511904145810915449
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"class": "FB",
|
"class": "FB",
|
||||||
"level": 79,
|
"level": 79,
|
||||||
"nation": "Procyon",
|
"nation": "Procyon",
|
||||||
"active": true,
|
"active": false,
|
||||||
"sharedWith": [
|
"sharedWith": [
|
||||||
"invicjusz"
|
"invicjusz"
|
||||||
]
|
]
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"class": "WI",
|
"class": "WI",
|
||||||
"level": 79,
|
"level": 79,
|
||||||
"nation": "Procyon",
|
"nation": "Procyon",
|
||||||
"active": false,
|
"active": true,
|
||||||
"sharedWith": [
|
"sharedWith": [
|
||||||
"invicjusz"
|
"invicjusz"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
26
data/poll-state.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -2,16 +2,76 @@
|
||||||
"2026-W23": {
|
"2026-W23": {
|
||||||
"weekKey": "2026-W23",
|
"weekKey": "2026-W23",
|
||||||
"entries": {
|
"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": [
|
"procyon": [
|
||||||
{
|
{
|
||||||
"usermapKey": "flash",
|
"userKey": "flash",
|
||||||
"characterName": "»Flash«",
|
"characterName": "»Flash«",
|
||||||
"class": "WI",
|
"class": "WI",
|
||||||
"nation": "Procyon",
|
"nation": "Procyon",
|
||||||
"weeklyPoints": 1861.1111111111113,
|
"weeklyPoints": 5179,
|
||||||
"tgCount": 3,
|
"tgCount": 7,
|
||||||
"currentRank": 1,
|
"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
|
"previousRank": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -19,7 +79,28 @@
|
||||||
"scoreIndex": {
|
"scoreIndex": {
|
||||||
"flash": [
|
"flash": [
|
||||||
"2026-06-01-20",
|
"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"
|
"2026-06-02-20"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
BIN
emoji-uploads/luminous_bringer.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
emoji-uploads/storm_bringer.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
emoji-uploads/wrank_1.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
emoji-uploads/wrank_1_gold.png
Normal file
|
After Width: | Height: | Size: 945 B |
BIN
emoji-uploads/wrank_2.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emoji-uploads/wrank_2_gold.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
emoji-uploads/wrank_3.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
emoji-uploads/wrank_3_gold.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
emoji-uploads/wrank_4.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
emoji-uploads/wrank_4_gold.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
emoji-uploads/wrank_5.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
emoji-uploads/wrank_5_gold.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emoji-uploads/wrank_down.png
Normal file
|
After Width: | Height: | Size: 283 B |
BIN
emoji-uploads/wrank_down_1.png
Normal file
|
After Width: | Height: | Size: 956 B |
BIN
emoji-uploads/wrank_down_2.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
emoji-uploads/wrank_down_3.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
emoji-uploads/wrank_down_4.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
emoji-uploads/wrank_down_5.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emoji-uploads/wrank_up.png
Normal file
|
After Width: | Height: | Size: 269 B |
BIN
emoji-uploads/wrank_up_1.png
Normal file
|
After Width: | Height: | Size: 943 B |
BIN
emoji-uploads/wrank_up_2.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
emoji-uploads/wrank_up_3.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
emoji-uploads/wrank_up_4.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/wrank_up_5.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -1,43 +1,41 @@
|
||||||
{
|
{
|
||||||
"capella": "<:capella:1511020911027814453>",
|
"bl": "<:bl:1511906439516651561>",
|
||||||
"procyon": "<:procyon:1511020943323955301>",
|
"borrowed": "<:borrowed:1511906443245391944>",
|
||||||
"bl": "<:bl:1511014332685881364>",
|
"capella": "<:capella:1511906447167062137>",
|
||||||
"fb": "<:fb:1511020923510194428>",
|
"dm": "<:dm:1511906450866180126>",
|
||||||
"fs": "<:fs:1511020931542417459>",
|
"fa": "<:fa:1511906454506967242>",
|
||||||
"fa": "<:fa:1511020918929887434>",
|
"fb": "<:fb:1511906458231377950>",
|
||||||
"fg": "<:fg:1511020927461097482>",
|
"fg": "<:fg:1511906461977022605>",
|
||||||
"gl": "<:gl:1511020935463833711>",
|
"fs": "<:fs:1511906465798029423>",
|
||||||
"dm": "<:dm:1511020914974658612>",
|
"gl": "<:gl:1511906470684524594>",
|
||||||
"wi": "<:wi:1511020959706910913>",
|
"kd": "<:kd:1511906474497146983>",
|
||||||
"wa": "<:wa:1511020955231715448>",
|
"luminous_bringer": "<:luminous_bringer:1511906480184492263>",
|
||||||
"wrank_up": "",
|
"procyon": "<:procyon:1511906483993055295>",
|
||||||
"wrank_down": "",
|
"rank": "<:rank:1511906488380293180>",
|
||||||
"atk": "",
|
"score": "<:score:1511906491903250525>",
|
||||||
"def": "",
|
"storm_bringer": "<:storm_bringer:1511906496097554594>",
|
||||||
"heal": "",
|
"wa": "<:wa:1511906499889467492>",
|
||||||
"wrank_neutral": "",
|
"wi": "<:wi:1511906503647563807>",
|
||||||
"wrank_1": "",
|
"wrank_1": "<:wrank_1:1511906507485085736>",
|
||||||
"wrank_1_gold": "",
|
"wrank_1_gold": "<:wrank_1_gold:1511906510806978742>",
|
||||||
"wrank_2": "",
|
"wrank_2": "<:wrank_2:1511906514745430217>",
|
||||||
"wrank_2_gold": "",
|
"wrank_2_gold": "<:wrank_2_gold:1511906518386212864>",
|
||||||
"wrank_3": "",
|
"wrank_3": "<:wrank_3:1511906522265944154>",
|
||||||
"wrank_3_gold": "",
|
"wrank_3_gold": "<:wrank_3_gold:1511906526204530690>",
|
||||||
"wrank_4": "",
|
"wrank_4": "<:wrank_4:1511906530692173915>",
|
||||||
"wrank_4_gold": "",
|
"wrank_4_gold": "<:wrank_4_gold:1511906534790266883>",
|
||||||
"wrank_5": "",
|
"wrank_5": "<:wrank_5:1511906539223388322>",
|
||||||
"wrank_5_gold": "",
|
"wrank_5_gold": "<:wrank_5_gold:1511906543342452837>",
|
||||||
"wrank_6": "",
|
"wrank_down": "<:wrank_down:1511906547104616643>",
|
||||||
"wrank_6_gold": "",
|
"wrank_down_1": "<:wrank_down_1:1511906550698999909>",
|
||||||
"wrank_7": "",
|
"wrank_down_2": "<:wrank_down_2:1511906554507694120>",
|
||||||
"wrank_7_gold": "",
|
"wrank_down_3": "<:wrank_down_3:1511906558231969792>",
|
||||||
"wrank_8": "",
|
"wrank_down_4": "<:wrank_down_4:1511906562011304007>",
|
||||||
"wrank_8_gold": "",
|
"wrank_down_5": "<:wrank_down_5:1511906565630984273>",
|
||||||
"wrank_9": "",
|
"wrank_up": "<:wrank_up:1511906568877117576>",
|
||||||
"wrank_9_gold": "",
|
"wrank_up_1": "<:wrank_up_1:1511906573537120287>",
|
||||||
"wrank_10": "",
|
"wrank_up_2": "<:wrank_up_2:1511906577970364536>",
|
||||||
"wrank_10_gold": "",
|
"wrank_up_3": "<:wrank_up_3:1511906581711945909>",
|
||||||
"kd": "<:kd:1511020939226124339>",
|
"wrank_up_4": "<:wrank_up_4:1511906585503338616>",
|
||||||
"score": "<:score:1511020950718513172>",
|
"wrank_up_5": "<:wrank_up_5:1511906588921954325>"
|
||||||
"rank": "<:rank:1511020947019137187>",
|
|
||||||
"borrowed": "<:borrowed:1511020906754085057>"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,31 +1,42 @@
|
||||||
/**
|
/**
|
||||||
* Bulk emoji upload script
|
* Bulk emoji upload script
|
||||||
* Usage: npx ts-node scripts/upload-emojis.ts [emoji_dir]
|
* Usage: npx ts-node -r tsconfig-paths/register 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
|
|
||||||
*
|
*
|
||||||
|
* 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.
|
* 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 { REST, Routes } from "discord.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
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");
|
const envPath = path.join(__dirname, "../.env");
|
||||||
if (fs.existsSync(envPath)) {
|
if (fs.existsSync(envPath)) {
|
||||||
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||||||
const [key, ...rest] = line.split("=");
|
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 TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
const GUILD_ID = process.env.GUILD_ID!;
|
const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
if (!TOKEN || !GUILD_ID) {
|
if (!TOKEN) {
|
||||||
console.error("❌ DISCORD_TOKEN and GUILD_ID must be set in .env");
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +51,48 @@
|
||||||
|
|
||||||
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
||||||
|
|
||||||
|
interface GuildEmojiSlot {
|
||||||
|
guildId: string;
|
||||||
|
name: string; // guild name for display
|
||||||
|
existing: Map<string, string>; // 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<GuildEmojiSlot[]> {
|
||||||
|
const slots: GuildEmojiSlot[] = [];
|
||||||
|
|
||||||
|
for (const guildId of guildIds) {
|
||||||
|
try {
|
||||||
|
const [guild, emojis] = await Promise.all([
|
||||||
|
rest.get(Routes.guild(guildId)) as Promise<any>,
|
||||||
|
rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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<void> {
|
async function uploadEmojis(): Promise<void> {
|
||||||
const files = fs.readdirSync(emojiDir).filter((f) =>
|
const files = fs.readdirSync(emojiDir).filter((f) =>
|
||||||
[".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase())
|
[".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.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 guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS);
|
||||||
const existing = await rest.get(Routes.guildEmojis(GUILD_ID)) as any[];
|
|
||||||
const existingMap = new Map(existing.map((e: any) => [e.name, e.id]));
|
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<string, string>(); // 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 uploaded = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let failed = 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) {
|
for (const file of files) {
|
||||||
const emojiName = path.basename(file, path.extname(file));
|
const emojiName = path.basename(file, path.extname(file));
|
||||||
const filePath = path.join(emojiDir, file);
|
const filePath = path.join(emojiDir, file);
|
||||||
const ext = path.extname(file).toLowerCase();
|
const ext = path.extname(file).toLowerCase();
|
||||||
const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png";
|
const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png";
|
||||||
|
|
||||||
if (existingMap.has(emojiName)) {
|
// Already exists in the pool — update map and skip
|
||||||
const formatted = `<:${emojiName}:${existingMap.get(emojiName)}>`;
|
if (globalExisting.has(emojiName)) {
|
||||||
emojiMap[emojiName] = formatted;
|
emojiMap[emojiName] = globalExisting.get(emojiName)!;
|
||||||
console.log(`⏭️ Already exists: ${emojiName} → ${formatted}`);
|
console.log(`⏭️ Already exists: ${emojiName} → ${emojiMap[emojiName]}`);
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
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 {
|
try {
|
||||||
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
||||||
const result = await rest.post(Routes.guildEmojis(GUILD_ID), {
|
const result = await rest.post(Routes.guildEmojis(slot.guildId), {
|
||||||
body: { name: emojiName, image: base64 },
|
body: { name: emojiName, image: base64 },
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
const formatted = `<:${emojiName}:${result.id}>`;
|
const formatted = `<:${emojiName}:${result.id}>`;
|
||||||
emojiMap[emojiName] = formatted;
|
emojiMap[emojiName] = formatted;
|
||||||
console.log(`✅ Uploaded: ${emojiName} → ${formatted}`);
|
slot.capacity--;
|
||||||
|
|
||||||
|
console.log(`✅ Uploaded: ${emojiName} → ${formatted} [${slot.name}]`);
|
||||||
uploaded++;
|
uploaded++;
|
||||||
|
|
||||||
// Avoid rate limiting
|
// Rate limit buffer
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ Failed: ${emojiName} — ${err.message}`);
|
console.error(`❌ Failed: ${emojiName} — ${err.message}`);
|
||||||
|
|
@ -105,6 +201,11 @@
|
||||||
|
|
||||||
console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`);
|
console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`);
|
||||||
console.log(`💾 messages/emojis.json updated`);
|
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);
|
uploadEmojis().catch(console.error);
|
||||||
|
|
@ -69,6 +69,7 @@ export function buildTgCommand(): SlashCommandBuilder {
|
||||||
)
|
)
|
||||||
.addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll")
|
.addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll")
|
||||||
.addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false))
|
.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("unlock").setDescription("Unlock the active poll"))
|
||||||
.addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening")
|
.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))
|
.addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false))
|
||||||
.addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").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("status").setDescription("Show current poll and config status"))
|
||||||
.addSubcommand((s) => s.setName("set-message").setDescription("Set public message override for a user")
|
.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)
|
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||||
|
|
|
||||||
|
|
@ -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 { cfg } from "@systems/config";
|
||||||
import { pollReplyAndDelete } from "../utils";
|
import { pollReplyAndDelete } from "../utils";
|
||||||
import { resolveUser } from "@systems/users";
|
import { resolveUser } from "@systems/users";
|
||||||
import { resolveMessage, nowFormatted } from "@systems/messages";
|
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||||
import { resolveNation } from "@systems/nations";
|
import { resolveNation } from "@systems/nations";
|
||||||
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
|
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
|
||||||
|
import { persist } from "@systems/pollPersistence"
|
||||||
import { showConflictEmbed } from "@systems/conflict";
|
import { showConflictEmbed } from "@systems/conflict";
|
||||||
import { getCharacters } from "@systems/characters";
|
import { getCharacters } from "@systems/characters";
|
||||||
import { getImpersonation } from "@systems/impersonate";
|
import { getImpersonation } from "@systems/impersonate";
|
||||||
import { format } from "@format";
|
import { format } from "@format";
|
||||||
import { Character } from "@src/types";
|
import { Character } from "@src/types";
|
||||||
|
import { modals } from "@handlers/modals";
|
||||||
|
|
||||||
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
||||||
|
|
||||||
|
|
@ -63,10 +71,21 @@ async function handleCharacterConflict(
|
||||||
const slot = [...polls.keys()][0];
|
const slot = [...polls.keys()][0];
|
||||||
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
|
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({
|
await interaction.followUp({
|
||||||
content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
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.`,
|
components: buttons,
|
||||||
ephemeral: true
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +200,7 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
||||||
|
|
||||||
const locked = clickCount >= LOCK_AT;
|
const locked = clickCount >= LOCK_AT;
|
||||||
if (locked) state.locked = true;
|
if (locked) state.locked = true;
|
||||||
|
persist.save(polls);
|
||||||
|
|
||||||
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
||||||
const msgContent = ephemeralMsg
|
const msgContent = ephemeralMsg
|
||||||
|
|
@ -195,3 +215,70 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
||||||
export function resetClickCounts(): void {
|
export function resetClickCounts(): void {
|
||||||
clickCounts.clear();
|
clickCounts.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Score submission button handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<StringSelectMenuBuilder>().addComponents(select);
|
||||||
|
|
||||||
|
// await interaction.editReply({
|
||||||
|
// content: "Which TG are you submitting for?",
|
||||||
|
// components: [row],
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
|
import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel, StringSelectMenuInteraction } from "discord.js";
|
||||||
import { handleButton } from "@handlers/buttons";
|
import { handleButton, handleScoreSubmitButton } from "@handlers/buttons";
|
||||||
import { handleTgCommand } from "@commands/tg";
|
import { handleTgCommand } from "@commands/tg";
|
||||||
import { handleTgConfigCommand } from "@commands/tgConfig";
|
import { handleTgConfigCommand } from "@commands/tgConfig";
|
||||||
import { handleBorrowAcceptButton } from "@subcommands/char/accept";
|
import { handleBorrowAcceptButton } from "@subcommands/char/accept";
|
||||||
|
|
@ -13,6 +13,7 @@ import { polls, updatePollMessage } from "@systems/poll";
|
||||||
import { cfg } from "@systems/config";
|
import { cfg } from "@systems/config";
|
||||||
import { resolveMessage, nowFormatted } from "@systems/messages";
|
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||||
import { format } from "@format";
|
import { format } from "@format";
|
||||||
|
import { modals } from "@handlers/modals";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|
@ -77,19 +78,27 @@ async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
|
||||||
publicMessage: publicMsg ?? undefined,
|
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()]) {
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||||
if (entry.userKey === userKey) {
|
if (entry.userKey === userKey) {
|
||||||
|
if (!existingVoteId) existingVoteId = id;
|
||||||
state.yes.delete(id);
|
state.yes.delete(id);
|
||||||
state.no.delete(id);
|
state.no.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const voteId = existingVoteId ?? btn.user.id;
|
||||||
|
|
||||||
if (prevVoteType === "yes") {
|
if (prevVoteType === "yes") {
|
||||||
state.yes.set(`switch_reclaim:${userKey}`, voteEntry);
|
state.yes.set(voteId, voteEntry);
|
||||||
} else {
|
} 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;
|
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
await updatePollMessage(channel, slot!);
|
await updatePollMessage(channel, slot!);
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +121,9 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
|
||||||
if (interaction.isButton()) {
|
if (interaction.isButton()) {
|
||||||
const btn = interaction as ButtonInteraction;
|
const btn = interaction as ButtonInteraction;
|
||||||
|
|
||||||
|
console.log("[interactions] interaction btnId:", btn.customId);
|
||||||
if (btn.customId.startsWith("conflict_")) {
|
if (btn.customId.startsWith("conflict_")) {
|
||||||
|
console.log("[interactions] routing to conflict handler:", btn.customId);
|
||||||
return await handleConflictButton(btn);
|
return await handleConflictButton(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,9 +145,27 @@ export async function handleInteraction(interaction: Interaction): Promise<void>
|
||||||
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
|
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btn.customId === "tg_score_submit") {
|
||||||
|
return await handleScoreSubmitButton(btn);
|
||||||
|
}
|
||||||
|
|
||||||
return await handleButton(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()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
const cmd = interaction as ChatInputCommandInteraction;
|
const cmd = interaction as ChatInputCommandInteraction;
|
||||||
if (cmd.commandName === "tg") await handleTgCommand(cmd);
|
if (cmd.commandName === "tg") await handleTgCommand(cmd);
|
||||||
|
|
|
||||||
156
src/handlers/modals.ts
Normal file
|
|
@ -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:<slot> — 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<TextInputBuilder>().addComponents(ptsInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(atkDefInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().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<void> {
|
||||||
|
if (interaction.customId.startsWith("score_submit:")) {
|
||||||
|
await handleScoreSubmit(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Future modals routed here by customId prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/index.ts
|
|
@ -1,15 +1,16 @@
|
||||||
import { Client, GatewayIntentBits, REST, Routes } from "discord.js";
|
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
|
||||||
import { loadConfig, cfg } from "./systems/config";
|
import { loadConfig, cfg } from "@systems/config";
|
||||||
import { loadMessages } from "./systems/messages";
|
import { loadMessages } from "@systems/messages";
|
||||||
import { loadEmojis } from "./systems/emojis";
|
import { loadEmojis } from "@systems/emojis";
|
||||||
import { loadCharacters } from "./systems/characters";
|
import { loadCharacters } from "@systems/characters";
|
||||||
import { loadWRank } from "./systems/wrank";
|
import { loadWRank } from "@systems/wrank";
|
||||||
import { scheduleSlots } from "./systems/slots";
|
import { scheduleSlots } from "@systems/slots";
|
||||||
import { postPoll, polls } from "./systems/poll";
|
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
|
||||||
import { handleInteraction } from "./handlers/interactions";
|
import { handleInteraction } from "@handlers/interactions";
|
||||||
import { buildTgCommand } from "./commands/tg";
|
import { buildTgCommand } from "@commands/tg";
|
||||||
import { buildTgConfigCommand } from "./commands/tgConfig";
|
import { buildTgConfigCommand } from "@commands/tgConfig";
|
||||||
import { TGSlot } from "./types";
|
import { TGSlot } from "@src/types";
|
||||||
|
import { persist } from "@systems/pollPersistence"
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||||
|
|
@ -28,21 +29,35 @@ async function registerCommands(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPollOpen(slot: TGSlot): Promise<void> {
|
async function onPollOpen(slot: TGSlot): Promise<void> {
|
||||||
const channelId = cfg("pollChannelId");
|
const channel = await client.channels.fetch(cfg("pollChannelId")) as any;
|
||||||
const channel = await client.channels.fetch(channelId) as any;
|
|
||||||
if (!channel) return console.error("Poll channel not found.");
|
if (!channel) return console.error("Poll channel not found.");
|
||||||
await postPoll(channel, slot);
|
await postPoll(channel, slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fires at tgHour exactly (e.g. 20:00) — voting closes, lockedYesKeys snapshotted
|
||||||
|
async function onPollLock(slot: TGSlot): Promise<void> {
|
||||||
|
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<void> {
|
async function onPollClose(slot: TGSlot): Promise<void> {
|
||||||
const state = polls.get(slot.tgHour);
|
const state = polls.get(slot.tgHour);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
state.locked = true;
|
|
||||||
const channelId = cfg("pollChannelId");
|
const channel = await client.channels.fetch(cfg("pollChannelId")) as any;
|
||||||
const channel = await client.channels.fetch(channelId) as any;
|
|
||||||
if (!channel) return;
|
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.`);
|
console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,25 +66,34 @@ client.on("interactionCreate", handleInteraction);
|
||||||
client.once("clientReady", async () => {
|
client.once("clientReady", async () => {
|
||||||
console.log(`Logged in as ${client.user!.tag}`);
|
console.log(`Logged in as ${client.user!.tag}`);
|
||||||
|
|
||||||
// Load all data
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
loadMessages();
|
loadMessages();
|
||||||
loadEmojis();
|
loadEmojis();
|
||||||
loadCharacters();
|
loadCharacters();
|
||||||
loadWRank();
|
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);
|
const guild = await client.guilds.fetch(GUILD_ID);
|
||||||
await guild.members.fetch();
|
await guild.members.fetch();
|
||||||
console.log(`Member cache warmed: ${guild.members.cache.size} members`);
|
console.log(`Member cache warmed: ${guild.members.cache.size} members`);
|
||||||
|
|
||||||
// Register commands if --register flag passed
|
|
||||||
if (process.argv.includes("--register")) {
|
if (process.argv.includes("--register")) {
|
||||||
await registerCommands();
|
await registerCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule slots
|
scheduleSlots(client, onPollOpen, onPollLock, onPollClose);
|
||||||
scheduleSlots(client, onPollOpen, onPollClose);
|
|
||||||
|
|
||||||
console.log("Bot ready.");
|
console.log("Bot ready.");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
33
src/scheduler.ts
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||||
import { cfg } from "../../systems/config";
|
import { cfg } from "@systems/config";
|
||||||
import { polls, updatePollMessage } from "../../systems/poll";
|
import { polls, lockPoll, updatePollMessage } from "@systems/poll";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "@utils";
|
||||||
|
|
||||||
export async function handleLock(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleLock(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const oneTimeMsg = interaction.options.getString("message") ?? undefined;
|
const oneTimeMsg = interaction.options.getString("message") ?? undefined;
|
||||||
const slot = getActiveSlot();
|
const simulateClose = interaction.options.getBoolean("simulate_close") ?? false;
|
||||||
if (!slot) return void replyAndDelete(interaction, "❌ No active poll found.");
|
const slot = getActiveSlot();
|
||||||
|
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||||
|
|
||||||
const state = polls.get(slot)!;
|
// Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron
|
||||||
state.locked = true;
|
lockPoll(slot);
|
||||||
|
|
||||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
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);
|
await updatePollMessage(channel, slot, oneTimeMsg);
|
||||||
return void replyAndDelete(interaction, "🔒 Poll locked.");
|
return void replyAndDelete(interaction, "🔒 Poll locked.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,88 @@
|
||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||||
import { loadMessages } from "../../systems/messages";
|
import { loadMessages } from "@systems/messages";
|
||||||
import { loadEmojis } from "../../systems/emojis";
|
import { loadEmojis } from "@systems/emojis";
|
||||||
import { loadCharacters } from "../../systems/characters";
|
import { loadCharacters } from "@systems/characters";
|
||||||
import { replyAndDelete } from "../../utils";
|
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<void> {
|
export async function handleReload(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
loadMessages(); // reloads global.json, usermap.json, users/*.json
|
const target = (interaction.options.getString("target") ?? "all") as Reloadable;
|
||||||
loadEmojis(); // reloads emojis.json
|
|
||||||
loadCharacters(); // reloads characters.json and accounts.json
|
const reloaded: string[] = [];
|
||||||
return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk.");
|
|
||||||
|
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<void> {
|
||||||
|
// 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(", ")}.`);
|
||||||
|
// }
|
||||||
12
src/subcommands/poll/reload.ts.bak
Normal file
|
|
@ -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<void> {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
|
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
|
||||||
import { cfg } from "../../systems/config";
|
import { cfg } from "@systems/config";
|
||||||
import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank";
|
import { getCurrentWeek, getWeekKey, getBringer } from "@systems/wrank";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { getEmoji } from "@systems/emojis";
|
||||||
|
import { replyAndDelete } from "@utils";
|
||||||
|
import { format } from "@src/systems/format";
|
||||||
|
|
||||||
export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const week = getCurrentWeek();
|
const week = getCurrentWeek();
|
||||||
|
|
@ -11,27 +13,51 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
|
||||||
const formatNation = (nation: "capella" | "procyon"): string => {
|
const formatNation = (nation: "capella" | "procyon"): string => {
|
||||||
const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank);
|
const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank);
|
||||||
if (entries.length === 0) return "—";
|
if (entries.length === 0) return "—";
|
||||||
|
|
||||||
const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon");
|
const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon");
|
||||||
|
|
||||||
return entries.map((e) => {
|
return entries.map((e) => {
|
||||||
const isDone = e.tgCount >= goal;
|
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}` : "";
|
// ── 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
|
const bringerStr = bringer === e.userKey && isDone
|
||||||
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
|
? ` · ${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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`)
|
.setTitle(`⚔️ TG Leaderboard — ${weekKey}`)
|
||||||
.setColor(0xe8a317)
|
.setColor(0xe8a317)
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: "🔵 Capella", value: formatNation("capella"), inline: true },
|
{ name: format.nation("Capella"), value: formatNation("capella"), inline: true },
|
||||||
{ name: "🔴 Procyon", value: formatNation("procyon"), inline: true },
|
{ name: format.nation("Procyon"), value: formatNation("procyon"), inline: true },
|
||||||
)
|
)
|
||||||
.setTimestamp();
|
.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 channelId = cfg("resultsChannelId") || cfg("pollChannelId");
|
||||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||||
await channel.send({ embeds: [embed] });
|
await channel.send({ embeds: [embed] });
|
||||||
|
|
|
||||||
39
src/subcommands/rank/post.ts.bak
Normal file
|
|
@ -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<void> {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
86
src/subcommands/score/submitCore.ts
Normal file
|
|
@ -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<ScoreSubmitResult> {
|
||||||
|
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}` : ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { polls, updatePollMessage } from "@systems/poll";
|
||||||
import { getClassEmoji } from "@systems/emojis";
|
import { getClassEmoji } from "@systems/emojis";
|
||||||
import { replyAndDelete } from "@src/utils";
|
import { replyAndDelete } from "@src/utils";
|
||||||
import { format } from "@format";
|
import { format } from "@format";
|
||||||
|
import { buildCharSelectButtons } from "@systems/charSelect";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|
@ -92,15 +93,28 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
|
||||||
const charDisplay = format.char(resolvedChar);
|
const charDisplay = format.char(resolvedChar);
|
||||||
const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
|
const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
return void replyAndDelete(interaction,
|
await interaction.reply({
|
||||||
`⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character and use the reclaim button that appears.`,
|
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:`,
|
||||||
true
|
components: buildCharSelectButtons(userKey, {
|
||||||
);
|
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||||
|
excludeCharName: resolvedChar.name,
|
||||||
|
appendToCustomId: ":yes",
|
||||||
|
}),
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return void replyAndDelete(interaction,
|
const buttons = buildCharSelectButtons(userKey, {
|
||||||
`❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||||
true
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
src/systems/charSelect.ts
Normal file
|
|
@ -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<ButtonBuilder>[] {
|
||||||
|
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<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
// 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<ButtonBuilder>().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<ButtonBuilder>().addComponents(...navBtns));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||||
import { getClassEmoji } from "@systems/emojis";
|
import { getClassEmoji } from "@systems/emojis";
|
||||||
import { format } from "@systems/format";
|
import { format } from "@systems/format";
|
||||||
import { Character } from "@types";
|
import { Character } from "@types";
|
||||||
|
import { buildCharSelectButtons } from "@systems/charSelect";
|
||||||
|
|
||||||
|
|
||||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
||||||
|
|
@ -122,6 +124,7 @@ export async function showConflictEmbed(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
|
export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
|
||||||
|
console.log("[conflict] button received:", interaction.customId);
|
||||||
const { customId } = interaction;
|
const { customId } = interaction;
|
||||||
|
|
||||||
// ── Pagination ──────────────────────────────────────────────────────────────
|
// ── Pagination ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -196,6 +199,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
|
||||||
|
|
||||||
// ── Reclaim ─────────────────────────────────────────────────────────────────
|
// ── Reclaim ─────────────────────────────────────────────────────────────────
|
||||||
if (customId.startsWith("conflict_reclaim:")) {
|
if (customId.startsWith("conflict_reclaim:")) {
|
||||||
|
console.log("[reclaim] handler triggered");
|
||||||
const parts = customId.split(":");
|
const parts = customId.split(":");
|
||||||
const ownerKey = parts[1];
|
const ownerKey = parts[1];
|
||||||
const borrowerKey = parts[2];
|
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;
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||||
await updatePollMessage(channel, slot!);
|
await updatePollMessage(channel, slot!);
|
||||||
|
|
||||||
|
console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER);
|
||||||
|
|
||||||
// Notify borrower if enabled and we have their Discord ID
|
// Notify borrower if enabled and we have their Discord ID
|
||||||
if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) {
|
if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) {
|
||||||
try {
|
try {
|
||||||
const borrowerMember = await guild.members.fetch(borrowerDiscordId);
|
const borrowerMember = await guild.members.fetch(borrowerDiscordId);
|
||||||
const borrowerChars = getCharacters(borrowerKey);
|
|
||||||
|
|
||||||
if (borrowerChars.length > 0) {
|
const btns = buildCharSelectButtons(borrowerKey, {
|
||||||
const btns = borrowerChars.slice(0, 5).map((c) =>
|
customIdPrefix: `switch_after_reclaim:${borrowerKey}`,
|
||||||
applyCharToButton(
|
excludeCharName: charName,
|
||||||
new ButtonBuilder()
|
appendToCustomId: `:${borrowerVoteType}`,
|
||||||
.setCustomId(`switch_after_reclaim:${borrowerKey}:${c.name}:${borrowerVoteType}`)
|
});
|
||||||
.setStyle(ButtonStyle.Secondary),
|
console.log("[reclaim notify] btns length:", btns.length);
|
||||||
c
|
console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON())));
|
||||||
)
|
await borrowerMember.send({
|
||||||
);
|
content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`,
|
||||||
await borrowerMember.send({
|
components: btns.length > 0 ? btns : [],
|
||||||
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<ButtonBuilder>().addComponents(...btns)],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await borrowerMember.send({
|
|
||||||
content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. You've been removed from the poll.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// DM may be disabled — silently ignore
|
// DM may be disabled — silently ignore
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ClassKey, Nation } from "@src/types";
|
import { ClassKey, Nation, WRankEntry } from "@src/types";
|
||||||
import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis";
|
import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis";
|
||||||
|
|
||||||
// ─── Individual formatters ────────────────────────────────────────────────────
|
// ─── Individual formatters ────────────────────────────────────────────────────
|
||||||
|
|
@ -55,6 +55,57 @@ function emoji(emojiStr: string): { name: string; id: string } | string | null {
|
||||||
return emojiStr;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Namespace export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const format = {
|
export const format = {
|
||||||
|
|
@ -62,4 +113,9 @@ export const format = {
|
||||||
nation,
|
nation,
|
||||||
score,
|
score,
|
||||||
emoji,
|
emoji,
|
||||||
|
wrank: {
|
||||||
|
rank: wrankRank,
|
||||||
|
delta: wrankDelta,
|
||||||
|
full: wrankFull,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -6,13 +6,17 @@ import {
|
||||||
TextChannel,
|
TextChannel,
|
||||||
GuildMember,
|
GuildMember,
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { PollState, VoteEntry, Nation, TGSlot } from "../types";
|
import { PollState, VoteEntry, Nation, TGSlot } from "@src/types";
|
||||||
import { cfg } from "./config";
|
import { cfg } from "@systems/config";
|
||||||
import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis";
|
import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis";
|
||||||
import { getActiveCharacter, getCharacterByName } from "./characters";
|
import { getActiveCharacter, getCharacterByName } from "@systems/characters";
|
||||||
import { resolveNation } from "./nations";
|
import { resolveNation } from "@systems/nations";
|
||||||
import { getEntry, getBringer } from "./wrank";
|
import { getEntry, getBringer } from "@systems/wrank";
|
||||||
import { nowFormatted } from "./messages";
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Poll state ───────────────────────────────────────────────────────────────
|
||||||
export const polls: Map<number, PollState> = new Map();
|
export const polls: Map<number, PollState> = new Map();
|
||||||
|
|
@ -51,30 +55,32 @@ export function resetPollOverrides(): void {
|
||||||
ephemeralOverrides.clear();
|
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 ────────────────────────────────────────────────────────
|
// ─── Character display ────────────────────────────────────────────────────────
|
||||||
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||||
const format = cfg("charDisplayFormat");
|
const cfgFormat = cfg("charDisplayFormat");
|
||||||
const nation = entry.characterNation;
|
const nation = entry.characterNation;
|
||||||
const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null;
|
const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null;
|
||||||
|
|
||||||
let wrank = "";
|
let wrank = "";
|
||||||
if (wRankEntry) {
|
if (wRankEntry) {
|
||||||
const goal = cfg("wRankGoal");
|
const wRankGoal = cfg("wRankGoal");
|
||||||
const isDone = wRankEntry.tgCount >= goal;
|
wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true });
|
||||||
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 classStr = entry.characterClass
|
const classStr = entry.characterClass
|
||||||
|
|
@ -85,7 +91,7 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||||
? `${entry.characterLevel}`
|
? `${entry.characterLevel}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
let row = format
|
let row = cfgFormat
|
||||||
.replace("{wrank}", wrank)
|
.replace("{wrank}", wrank)
|
||||||
.replace("{class}", classStr)
|
.replace("{class}", classStr)
|
||||||
.replace("{level}", levelStr)
|
.replace("{level}", levelStr)
|
||||||
|
|
@ -94,15 +100,33 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Bringer title — independent of W.Rank so override always shows
|
// 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) {
|
if (nation && entry.userKey) {
|
||||||
const bringer = getBringer(nation);
|
const bringer = getBringer(nation);
|
||||||
if (bringer && bringer === entry.characterName) {
|
if (bringer && bringer === entry.characterName) {
|
||||||
const emoji = nation === "Capella"
|
const bringerTitle = getNationBringerTitle(nation);
|
||||||
? (getEmoji("luminous_bringer") || "🔆")
|
row += ` · ${bringerTitle}`;
|
||||||
: (getEmoji("storm_bringer") || "⚡");
|
|
||||||
const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
|
|
||||||
row += ` · ${emoji} **${title}**`;
|
|
||||||
}
|
}
|
||||||
|
// 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) {
|
if (entry.borrowedFrom) {
|
||||||
|
|
@ -203,21 +227,41 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> {
|
export function buildButtons(
|
||||||
|
disabled: boolean,
|
||||||
|
showSubmit?: boolean
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
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<ButtonBuilder>().addComponents(submitBtn)];
|
||||||
|
}
|
||||||
|
|
||||||
const yesBtn = new ButtonBuilder()
|
const yesBtn = new ButtonBuilder()
|
||||||
.setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
|
.setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
|
||||||
const noBtn = new ButtonBuilder()
|
const noBtn = new ButtonBuilder()
|
||||||
.setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
|
.setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
|
||||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
|
return [new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
|
export async function updatePollMessage(
|
||||||
|
channel: TextChannel,
|
||||||
|
slot: number,
|
||||||
|
overrideLockMsg?: string,
|
||||||
|
showSubmit?: boolean
|
||||||
|
): Promise<void> {
|
||||||
const state = polls.get(slot);
|
const state = polls.get(slot);
|
||||||
if (!state?.messageId) return;
|
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 {
|
try {
|
||||||
const msg = await channel.messages.fetch(state.messageId);
|
const msg = await channel.messages.fetch(state.messageId);
|
||||||
const disabled = state.locked || state.confirmed !== null;
|
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buttons });
|
||||||
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update poll message:", 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<void> {
|
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
|
||||||
resetPollOverrides();
|
resetPollOverrides();
|
||||||
const { clearSessionBorrows } = require("@systems/borrow");
|
persist.clear();
|
||||||
const { clearAllImpersonations } = require("@systems/impersonate");
|
|
||||||
|
|
||||||
clearSessionBorrows();
|
clearSessionBorrows();
|
||||||
clearAllImpersonations();
|
clearAllImpersonations();
|
||||||
|
|
@ -237,9 +280,11 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void
|
||||||
locked: false, confirmed: null,
|
locked: false, confirmed: null,
|
||||||
};
|
};
|
||||||
polls.set(slot.tgHour, state);
|
polls.set(slot.tgHour, state);
|
||||||
const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] });
|
const msg = await channel.send({ embeds: [buildEmbed(state)], components: buildButtons(false) });
|
||||||
state.messageId = msg.id;
|
state.messageId = msg.id;
|
||||||
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
|
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
|
||||||
|
|
||||||
|
persist.save(polls)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVoteEntry(
|
export function createVoteEntry(
|
||||||
|
|
@ -252,7 +297,7 @@ export function createVoteEntry(
|
||||||
const globalNickname = member.user.globalName ?? null;
|
const globalNickname = member.user.globalName ?? null;
|
||||||
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
||||||
|
|
||||||
const { getEffectiveCharacter } = require("./borrow");
|
const { getEffectiveCharacter } = require("@systems/borrow");
|
||||||
const { char, borrowedFrom: bf } = userKey
|
const { char, borrowedFrom: bf } = userKey
|
||||||
? getEffectiveCharacter(userKey)
|
? getEffectiveCharacter(userKey)
|
||||||
: { char: null, borrowedFrom: null };
|
: { char: null, borrowedFrom: null };
|
||||||
|
|
|
||||||
88
src/systems/pollPersistence.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { PollState, VoteEntry } from "@src/types";
|
||||||
|
|
||||||
|
const PERSIST_PATH = path.join(__dirname, "../../data/poll-state.json");
|
||||||
|
|
||||||
|
// ─── Serialized shape ─────────────────────────────────────────────────────────
|
||||||
|
// Maps → arrays of [key, value] tuples, Sets → arrays of values
|
||||||
|
|
||||||
|
interface SerializedPollState {
|
||||||
|
messageId: string | null;
|
||||||
|
slot: number;
|
||||||
|
yes: [string, VoteEntry][];
|
||||||
|
no: [string, VoteEntry][];
|
||||||
|
locked: boolean;
|
||||||
|
confirmed: "yes" | "no" | null;
|
||||||
|
lockMessage?: string;
|
||||||
|
confirmMessage?: string;
|
||||||
|
lockedYesKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Serialize / deserialize ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function serialize(polls: Map<number, PollState>): 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<number, PollState> {
|
||||||
|
const polls = new Map<number, PollState>();
|
||||||
|
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<number, PollState>): 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<number, PollState> | 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
import { Client } from "discord.js";
|
import { Client, TextChannel } from "discord.js";
|
||||||
import { cfg } from "./config";
|
import { cfg } from "./config";
|
||||||
import { TGSlot } from "../types";
|
import { TGSlot } from "../types";
|
||||||
|
import { polls, updatePollMessage } from "@systems/poll";
|
||||||
|
|
||||||
type PollCallback = (slot: TGSlot) => Promise<void>;
|
type PollCallback = (slot: TGSlot) => Promise<void>;
|
||||||
type CloseCallback = (slot: TGSlot) => Promise<void>;
|
type CloseCallback = (slot: TGSlot) => Promise<void>;
|
||||||
|
type LockCallback = (slot: TGSlot) => Promise<void>;
|
||||||
|
|
||||||
let _scheduledTasks: cron.ScheduledTask[] = [];
|
let _scheduledTasks: cron.ScheduledTask[] = [];
|
||||||
|
|
||||||
export function scheduleSlots(
|
export function scheduleSlots(
|
||||||
client: Client,
|
client: Client,
|
||||||
onPollOpen: PollCallback,
|
onPollOpen: PollCallback,
|
||||||
onPollClose: CloseCallback
|
onPollLock: LockCallback,
|
||||||
|
onPollClose: CloseCallback,
|
||||||
): void {
|
): void {
|
||||||
// Clear existing schedules
|
|
||||||
_scheduledTasks.forEach((t) => t.stop());
|
_scheduledTasks.forEach((t) => t.stop());
|
||||||
_scheduledTasks = [];
|
_scheduledTasks = [];
|
||||||
|
|
||||||
|
|
@ -21,37 +23,46 @@ export function scheduleSlots(
|
||||||
const slots = cfg("slots").filter((s) => s.active);
|
const slots = cfg("slots").filter((s) => s.active);
|
||||||
|
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
// Parse poll open time
|
// Poll open
|
||||||
const [openHour, openMin] = slot.pollOpens.split(":").map(Number);
|
const [openHour, openMin] = slot.pollOpens.split(":").map(Number);
|
||||||
|
_scheduledTasks.push(cron.schedule(
|
||||||
// Schedule poll open
|
|
||||||
const openTask = cron.schedule(
|
|
||||||
`${openMin} ${openHour} * * *`,
|
`${openMin} ${openHour} * * *`,
|
||||||
() => onPollOpen(slot),
|
() => onPollOpen(slot),
|
||||||
{ timezone: tz }
|
{ 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 closeMinTotal = slot.tgHour * 60 + slot.closesAfter;
|
||||||
const closeHour = Math.floor(closeMinTotal / 60) % 24;
|
const closeHour = Math.floor(closeMinTotal / 60) % 24;
|
||||||
const closeMin = closeMinTotal % 60;
|
const closeMin = closeMinTotal % 60;
|
||||||
|
_scheduledTasks.push(cron.schedule(
|
||||||
const closeTask = cron.schedule(
|
|
||||||
`${closeMin} ${closeHour} * * *`,
|
`${closeMin} ${closeHour} * * *`,
|
||||||
() => onPollClose(slot),
|
() => onPollClose(slot),
|
||||||
{ timezone: tz }
|
{ 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
|
// Weekly reset — Monday 00:00
|
||||||
const resetTask = cron.schedule("0 0 * * 1", () => {
|
_scheduledTasks.push(cron.schedule("0 0 * * 1", () => {
|
||||||
const { resetWeek } = require("./wrank");
|
const { resetWeek } = require("./wrank");
|
||||||
resetWeek();
|
resetWeek();
|
||||||
console.log("W.Rank weekly reset complete.");
|
console.log("W.Rank weekly reset complete.");
|
||||||
}, { timezone: tz });
|
}, { timezone: tz }));
|
||||||
_scheduledTasks.push(resetTask);
|
|
||||||
|
|
||||||
console.log(`Scheduled ${slots.length} slot(s).`);
|
console.log(`Scheduled ${slots.length} slot(s).`);
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +119,7 @@ export interface PollState {
|
||||||
confirmed: "yes" | "no" | null;
|
confirmed: "yes" | "no" | null;
|
||||||
lockMessage?: string;
|
lockMessage?: string;
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
|
lockedYesKeys?: Set<string>; // snapshot of userKeys in yes at lock time
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scores ──────────────────────────────────────────────────────────────────
|
// ─── Scores ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||