Compare commits

..

2 commits

Author SHA1 Message Date
Nuno Duque Nunes
8ffe8348bb update .gitignore 2026-06-04 03:09:12 +01:00
Nuno Duque Nunes
29aa853723 feat: character sharing/borrowing, impersonation, conflict resolution, W.Rank per char, autocomplete, UI improvements 2026-06-04 03:08:01 +01:00
51 changed files with 1345 additions and 227 deletions

5
.env
View file

@ -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

3
.gitignore vendored
View file

@ -14,6 +14,9 @@ data/bringer.json
data/sessionPreferences.json data/sessionPreferences.json
data/tg-history/ data/tg-history/
# Emoji data
emoji-uploads/
# Keep the data directory structure but not the contents # Keep the data directory structure but not the contents
!data/.gitkeep !data/.gitkeep
!data/tg-history/.gitkeep !data/tg-history/.gitkeep

View file

@ -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
View 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
}
]

View file

@ -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"
] ]
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
emoji-uploads/wrank_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

BIN
emoji-uploads/wrank_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

BIN
emoji-uploads/wrank_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
emoji-uploads/wrank_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
emoji-uploads/wrank_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
emoji-uploads/wrank_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -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>"
} }

View file

@ -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);

View file

@ -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)

View file

@ -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],
// });
// }

View file

@ -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
View 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);
}
}

View file

@ -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
View 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.

View file

@ -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 simulateClose = interaction.options.getBoolean("simulate_close") ?? false;
const slot = getActiveSlot(); const slot = getActiveSlot();
if (!slot) return void replyAndDelete(interaction, "❌ No active poll found."); 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.");
} }

View file

@ -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(", ")}.`);
// }

View 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.");
}

View file

@ -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})`;
// ── Score indicator ───────────────────────────────────────────────────
const scoreStr = format.score(e.weeklyPoints);
return `${rankStr}(${deltaStr}) ${charStr}${scoreStr} ${bringerStr}`;
}).join("\n"); }).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] });

View 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.");
}

View 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}` : ""}`,
};
}
}

View file

@ -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
View 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;
}

View file

@ -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),
c
)
);
await borrowerMember.send({
content: `⚠️ **${format.char({ class: char?.class ?? "FB" as any, level: char?.level ?? 0, name: charName })}** was reclaimed by **${ownerKey}**. Pick another character:`,
components: [new ActionRowBuilder<ButtonBuilder>().addComponents(...btns)],
}); });
} else { console.log("[reclaim notify] btns length:", btns.length);
console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON())));
await borrowerMember.send({ await borrowerMember.send({
content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. You've been removed from the poll.`, content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`,
components: btns.length > 0 ? btns : [],
}); });
}
} catch { } catch {
// DM may be disabled — silently ignore // DM may be disabled — silently ignore
} }

View file

@ -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,
},
}; };

View file

@ -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 };

View 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);
}
}
}

View file

@ -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).`);
} }

View file

@ -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 ──────────────────────────────────────────────────────────────────