diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts index 8e7cafb..54bdf32 100644 --- a/scripts/upload-emojis.ts +++ b/scripts/upload-emojis.ts @@ -20,6 +20,7 @@ import { REST, Routes } from "discord.js"; import fs from "fs"; import path from "path"; + import { Config } from "@systems/config"; // Load .env const envPath = path.join(__dirname, "../.env"); @@ -31,8 +32,7 @@ } const TOKEN = process.env.DISCORD_TOKEN!; - const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "") - .split(",").map((id) => id.trim()).filter(Boolean); + const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds"); if (!TOKEN || DONOR_GUILD_IDS.length === 0) { console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env"); diff --git a/src/commands/tgConfig.ts b/src/commands/tgConfig.ts index 9aa6dfe..b6fcc74 100644 --- a/src/commands/tgConfig.ts +++ b/src/commands/tgConfig.ts @@ -3,6 +3,7 @@ import { cfg, setCfg, resetCfg } from "../systems/config"; import { hasOfficerRole } from "../systems/users"; import { replyAndDelete } from "../utils"; import { Nation } from "@types"; +import { handleSetLayout } from "@subcommands/tg-config/set-layout"; export function buildTgConfigCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() @@ -101,6 +102,21 @@ export function buildTgConfigCommand(): SlashCommandBuilder { .addStringOption(nationOpt)) ); + // ── poll group ─────────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("poll") + .setDescription("Configure Poll Settings") + .addSubcommand((s) => s + .setName("set-layout") + .setDescription("Change the poll display layout") + .addStringOption((o) => o + .setName("layout") + .setDescription("Layout name") + .setRequired(true) + .setAutocomplete(true) + )) + ); + return cmd; } @@ -208,4 +224,8 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac if (sub === "set-no-display") { setCfg("showNoInNationField" as any, interaction.options.getString("mode", true) === "inline"); return void replyAndDelete(interaction, "✅ No voter display updated."); } if (sub === "set-nation-source"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source updated."); } } + + if (group === "poll") { + if (sub === "set-layout") return handleSetLayout(interaction); + } } \ No newline at end of file diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index 972c386..4888f2d 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -8,6 +8,7 @@ import { UserRegistry } from "@registry/user-registry"; import { Paths } from "@helpers/paths"; import { Nation } from "@types"; import { NATION_UNICODE } from "@systems/nations"; +import { autocompleteLayout } from "@subcommands/tg-config/set-layout"; import fs from "fs"; // ─── Usermap cache ──────────────────────────────────────────────────────────── @@ -127,6 +128,7 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction): if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); + if (optionName === "layout") return await autocompleteLayout(interaction); await interaction.respond([]); } catch (err) { diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index 59d29f5..0c662c0 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -24,9 +24,10 @@ import { Ephemeral } from "@registry/ephemeral-registry"; import { Nation, CLASSES } from "@types"; import { InteractionLock } from "@helpers/interaction-lock"; import { Benchmark } from "@systems/benchmark"; +import { Config } from "@systems/config"; -const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); +const LOCK_AT = Config.get("lockAt"); const clickCounts = new Map(); const _processingVotes = new Set(); @@ -244,7 +245,7 @@ export async function showActiveCharSwitching(interaction: ButtonInteraction): P const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); bench.mark("getEffectiveCharacter"); if (char) { - const starEmoji = process.env.ACTIVE_CHAR_EMOJI || "⭐"; + const starEmoji = Config.get("activeCharEmoji"); const borrowNote = borrowedFrom ? ` 🔗` : ""; const buttons = buildCharSelectButtons(user.userKey, { customIdPrefix: `companion_switch:${user.userKey}`, diff --git a/src/subcommands/poll/start.ts b/src/subcommands/poll/start.ts index a0078eb..9b20ac0 100644 --- a/src/subcommands/poll/start.ts +++ b/src/subcommands/poll/start.ts @@ -4,6 +4,7 @@ import { postPoll } from "../../systems/poll"; import { resetClickCounts } from "../../handlers/buttons"; import { replyAndDelete } from "../../utils"; import { TGSlot } from "../../types"; +import { Config } from "@systems/config"; export async function handleStart(interaction: ChatInputCommandInteraction): Promise { const slotArg = interaction.options.getString("slot"); @@ -19,9 +20,9 @@ export async function handleStart(interaction: ChatInputCommandInteraction): Pro } if (!slot) return void replyAndDelete(interaction, "❌ No active TG slots configured."); - const channelId = cfg("pollChannelId"); + const channelId = Config.get("pollChannelId"); console.log("pollChannelId:", channelId); - console.log("POLL_CHANNEL_ID env:", process.env.POLL_CHANNEL_ID); + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found."); diff --git a/src/subcommands/tg-config/set-layout.ts b/src/subcommands/tg-config/set-layout.ts new file mode 100644 index 0000000..c2cd632 --- /dev/null +++ b/src/subcommands/tg-config/set-layout.ts @@ -0,0 +1,37 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { Config } from "@systems/config"; +import { PollUI } from "@ui/poll"; +import { replyAndDelete } from "@utils"; +import { hasOfficerRole } from "@systems/users"; + +export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, Config.get("officerRoles"))) { + return void replyAndDelete(interaction, "❌ Only officers can change the poll layout.", true); + } + + const name = interaction.options.getString("layout", true); + + if (!PollUI.setLayout(name)) { + const available = PollUI.layouts() + .map((l) => `\`${l.name}\` — ${l.description}`) + .join("\n"); + return void replyAndDelete(interaction, + `❌ Layout \`${name}\` not found. Available layouts:\n${available}`, true + ); + } + + Config.set("pollLayout", name); + + return void replyAndDelete(interaction, + `✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true + ); +} + +export async function autocompleteLayout(interaction: any): Promise { + const focused = interaction.options.getFocused().toLowerCase(); + const choices = PollUI.layouts() + .filter((l) => l.name.includes(focused)) + .map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name })); + await interaction.respond(choices); +} \ No newline at end of file diff --git a/src/systems/benchmark.ts b/src/systems/benchmark.ts index dd61f2a..4ac361f 100644 --- a/src/systems/benchmark.ts +++ b/src/systems/benchmark.ts @@ -18,6 +18,7 @@ */ import { Logger } from "@systems/logger"; + import { Config } from "@systems/config"; const log = Logger.for("benchmark"); @@ -54,7 +55,7 @@ // ─── Namespace ──────────────────────────────────────────────────────────────── // Only profile when LOG_LEVEL=debug - const _enabled = () => process.env.LOG_LEVEL?.toUpperCase() === "DEBUG"; + const _enabled = () => Config.get("logLevel").toUpperCase() === "DEBUG"; export const Benchmark = { /** diff --git a/src/systems/config.ts b/src/systems/config.ts index 46a9bfa..dc0a279 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -1,64 +1,201 @@ -import path from "path"; -import { BotConfig, Nation } from "../types"; +import { Nation, TGSlot } from "@types"; import { Store } from "@systems/store"; -import { Paths } from "@helpers/paths"; +import { Paths } from "@paths"; +// ─── Sub-interfaces ─────────────────────────────────────────────────────────── + +interface ChannelConfig { + pollChannelId: string; + resultsChannelId: string; + scoreChannelId: string; +} + +interface RoleConfig { + officerRoles: string[]; + configRoles: string[]; + tagRoles: string[]; +} + +interface PollConfig { + pollLayout: string; + pollEphemeralEnabled: boolean; + commandEphemeralEnabled: boolean; + ephemeralDeleteMs: number; + lockAt: number; + lockMessage: string; + confirmYesMessage: string; + confirmNoMessage: string; + charDisplayFormat: string; + showClassInMessages: boolean; + showLevelInMessages: boolean; + showNoInNationField: boolean; + showNationTotalsInHeader: boolean; + autoVoteOnConflictSwitch: boolean; + reclaimNotifyBorrower: boolean; + conflictReclaimBehavior: string; + slots: TGSlot[]; + scoreWindowHours: number; + tgDurationMinutes: number; +} + +interface WRankConfig { + wRankGoal: number; + wRankPostOnReset: boolean; + wRankYellowColor: string; + wRankGrayColor: string; + deltaUpColor: string; + deltaDownColor: string; +} + +interface BringerConfig { + stormBringerColor: string; + luminousBringerColor: string; +} + +interface ImpersonateConfig { + impersonateResetOnPoll: boolean; + impersonateIndicator: boolean; +} + +interface EmojiConfig { + activeCharEmoji: string; + emojiDonorGuilds: string[]; +} + +interface LoggingConfig { + logLevel: string; +} + +interface NationConfig { + nationSource: Nation; +} + +interface BorrowConfig { + borrowRequestExpiryMs: number; +} + +// ─── BotConfig — merged, all fields optional ────────────────────────────────── + +export type BotConfig = Partial< + ChannelConfig & + RoleConfig & + PollConfig & + WRankConfig & + BringerConfig & + ImpersonateConfig & + EmojiConfig & + LoggingConfig & + NationConfig & + BorrowConfig +>; + +// ─── Defaults ───────────────────────────────────────────────────────────────── -// Function instead of const so env vars are read lazily at call time function getDefaults(): Required { return { + // Channels + pollChannelId: "", + resultsChannelId: "", + scoreChannelId: "", + + // Roles officerRoles: ["Ice King"], configRoles: ["Ice King"], tagRoles: ["Ice King", "Ice", "Rebellion"], + + // Poll + pollLayout: "default", + pollEphemeralEnabled: false, + commandEphemeralEnabled: true, + ephemeralDeleteMs: 0, + lockAt: 10, lockMessage: "🔒 This poll has been locked.", confirmYesMessage: "⚔️ TG is confirmed for tonight!", confirmNoMessage: "❌ TG is cancelled for tonight.", - pollChannelId: process.env.POLL_CHANNEL_ID ?? "", - resultsChannelId: process.env.RESULTS_CHANNEL_ID ?? "", - scoreChannelId: process.env.SCORE_CHANNEL_ID ?? "", - slots: [ - { tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }, - ], + charDisplayFormat: "{wrank} {class} {level} {name}", + showClassInMessages: false, + showLevelInMessages: false, + showNoInNationField: false, + showNationTotalsInHeader: false, + autoVoteOnConflictSwitch: true, + reclaimNotifyBorrower: true, + conflictReclaimBehavior: "revert", + slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], scoreWindowHours: 2, tgDurationMinutes: 35, - nationSource: Nation.Procyon, - wRankPostOnReset: false, + + // W.Rank wRankGoal: 7, + wRankPostOnReset: false, wRankYellowColor: "#BA7517", wRankGrayColor: "#888888", deltaUpColor: "#A32D2D", deltaDownColor: "#185FA5", + + // Bringer stormBringerColor: "#185FA5", luminousBringerColor: "#8B4CB8", - showClassInMessages: false, - showLevelInMessages: false, - charDisplayFormat: "{wrank} {class} {level} {name}", - showNationTotalsInHeader: false, - showNoInNationField: false, - borrowRequestExpiryMs: 0, // 0 = never expire - conflictReclaimBehavior: "revert", + + // Impersonate + impersonateResetOnPoll: false, + impersonateIndicator: true, + + // Emoji + activeCharEmoji: "⭐", + emojiDonorGuilds: [], + + // Logging + logLevel: "info", + + // Nation + nationSource: Nation.Procyon, + + // Borrow + borrowRequestExpiryMs: 0, }; } +// ─── State ──────────────────────────────────────────────────────────────────── + let _cfg: BotConfig = {}; -export function loadConfig(): void { - _cfg = Store.readOrDefault(Paths.data("config.json"), {}); -} -export function saveConfig(): void { - Store.write(Paths.data("config.json"), _cfg); -} +// ─── Config namespace ───────────────────────────────────────────────────────── -export function cfg(key: K): Required[K] { - return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required[K]; -} +export const Config = { + load(): void { + _cfg = Store.readOrDefault(Paths.data("config.json"), {}); + }, -export function setCfg(key: K, value: BotConfig[K]): void { - _cfg[key] = value; - saveConfig(); -} + save(): void { + Store.write(Paths.data("config.json"), _cfg); + }, -export function resetCfg(key: K): void { - delete _cfg[key]; - saveConfig(); -} \ No newline at end of file + reload(): void { + Config.load(); + }, + + get(key: K): Required[K] { + return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required[K]; + }, + + set(key: K, value: BotConfig[K]): void { + _cfg[key] = value; + Config.save(); + }, + + reset(key: K): void { + delete _cfg[key]; + Config.save(); + }, + + all(): Required { + return { ...getDefaults(), ..._cfg }; + }, +}; + +// ─── Legacy aliases — remove after full migration ──────────────────────────── +export const cfg = (key: K) => Config.get(key); +export const setCfg = (key: K, value: BotConfig[K]) => Config.set(key, value); +export const resetCfg = (key: K) => Config.reset(key); +export const loadConfig = () => Config.load(); +export const saveConfig = () => Config.save(); \ No newline at end of file diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts index 04bceae..be55b59 100644 --- a/src/systems/conflict.ts +++ b/src/systems/conflict.ts @@ -16,13 +16,14 @@ import { Emoji } from "@systems/emojis"; import { format } from "@systems/format"; import { Character } from "@types"; import { buildCharSelectButtons } from "@systems/charSelect"; +import { Config } from "@systems/config"; // ─── Config ─────────────────────────────────────────────────────────────────── const RECLAIM_STYLE = ButtonStyle.Secondary; const SWITCH_STYLE = ButtonStyle.Secondary; -const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false"; -const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false"; +const AUTO_VOTE_ON_SWITCH = Config.get("autoVoteOnConflictSwitch"); +const RECLAIM_NOTIFY_BORROWER = Config.get("reclaimNotifyBorrower"); // ─── State ──────────────────────────────────────────────────────────────────── const pendingConflicts = new Map(); diff --git a/src/systems/logger.ts b/src/systems/logger.ts index e6d867b..676c60f 100644 --- a/src/systems/logger.ts +++ b/src/systems/logger.ts @@ -12,6 +12,8 @@ * log.debug("State:", state); */ + import { Config } from "@systems/config"; + // ─── Log levels ─────────────────────────────────────────────────────────────── export enum LogLevel { @@ -68,7 +70,7 @@ function getIcon(context: string): string { // ─── Global config ──────────────────────────────────────────────────────────── let _globalLevel: LogLevel = (() => { - const env = process.env.LOG_LEVEL?.toUpperCase(); + const env = Config.get("logLevel").toUpperCase(); switch (env) { case "DEBUG": return LogLevel.Debug; case "WARN": return LogLevel.Warn; diff --git a/src/systems/poll.ts b/src/systems/poll.ts index 6d1251f..9b632e8 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -17,6 +17,7 @@ import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow"; import { clearAllImpersonations } from "@systems/impersonate"; import { Bringer } from "@systems/bringer"; import { Attendance } from "@systems/attendance"; +import { PollUI } from "@ui/poll"; // ─── Poll state ─────────────────────────────────────────────────────────────── @@ -72,147 +73,7 @@ export function lockPoll(slot: number): void { Attendance.snapshot(slot, state.lockedYesKeys); } - -// ─── Character display ──────────────────────────────────────────────────────── - -function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank = false): string { - const cfgFormat = cfg("charDisplayFormat"); - const nation = entry.characterNation; - const wRankEntry = entry.characterName && entry.characterNation - ? WRank.entry(entry.characterName, entry.characterNation) - : null; - - let wrank = ""; - if (wRankEntry) { - const wRankGoal = cfg("wRankGoal"); - wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true }); - } else if (nationHasRank) { - wrank = format.wrank.noRank(); - } - - const classStr = entry.characterClass - ? (Emoji.class(entry.characterClass) || entry.characterClass) - : ""; - - const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any) - ? `${entry.characterLevel}` - : ""; - - let row = cfgFormat - .replace("{wrank}", wrank) - .replace("{class}", classStr) - .replace("{level}", levelStr) - .replace("{name}", entry.characterName ?? entry.displayName ?? "") - .replace(/\s+/g, " ") - .trim(); - - // Bringer title — independent of W.Rank so override always shows - if (nation && entry.userKey) { - const bringer = Bringer.get({ nation }); - - if (bringer && bringer === entry.characterName) { - row += ` · ${format.bringer(nation)}`; - } - } - - if (entry.borrowedFrom) { - row += ` ${Emoji.get("borrowed") || "🔗"}`; - } - - if (showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; - - return row; -} - // ─── Embed building ─────────────────────────────────────────────────────────── -export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder { - const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] }; - const noVoters: VoteEntry[] = []; - const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; - const showNoInline = (cfg as any)("showNoInNationField") ?? false; - - for (const entry of state.yes.values()) { - const nation = entry.characterNation ?? Nation.Capella; - yesByNation[nation].push(entry); - allMessages.push({ entry, voteType: "yes" }); - } - for (const entry of state.no.values()) { - noVoters.push(entry); - allMessages.push({ entry, voteType: "no" }); - } - - const capellaEmoji = Emoji.get("capella"); - const procyonEmoji = Emoji.get("procyon"); - - const formatNationField = (nation: Nation): string => { - const yesEntries = yesByNation[nation]; - const hasRank = yesEntries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null); - const noEntries = showNoInline - ? noVoters.filter((e) => e.characterNation === nation) - : []; - const lines = [ - ...yesEntries.map((e) => formatCharRow(e, false, hasRank)), - ...noEntries.map((e) => `❌ ${formatCharRow(e, false, hasRank)}`), - ]; - return lines.length > 0 ? lines.join("\n") : "—"; - }; - - const formatMessages = (): string => { - if (allMessages.length === 0) return ""; - return allMessages - .map((m) => { - const name = m.entry.characterName ?? m.entry.displayName; - const prefix = m.voteType === "no" ? "✗ " : "✓ "; - const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : ""; - return `${prefix}${name} · ${m.entry.votedAt}${msg}`; - }) - .join("\n"); - }; - - const locked = state.locked; - const confirmed = state.confirmed; - - const color = - confirmed === "yes" ? 0x57f287 : - confirmed === "no" ? 0xed4245 : - locked ? 0x888888 : - 0xe8a317; - - // Title with nation + no counts (hidden when confirmed or locked) - const counts = !locked && confirmed === null - ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}` - : ""; - const statusSuffix = - locked ? " 🔒" : - confirmed === "yes" ? " ✅" : - confirmed === "no" ? " ❌" : ""; - - const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; - - const embed = new EmbedBuilder() - .setTitle(title) - .setColor(color) - .addFields( - { name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, value: formatNationField(Nation.Capella), inline: false }, - { name: "\u200b", value: "\u200b", inline: false }, - { name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, value: formatNationField(Nation.Procyon), inline: false }, - ) - .setTimestamp(); - - const msgSection = formatMessages(); - if (msgSection) { - embed.addFields({ name: "\u200b", value: msgSection, inline: false }); - } - - let footer: string; - if (confirmed === "yes") footer = cfg("confirmYesMessage"); - else if (confirmed === "no") footer = cfg("confirmNoMessage"); - else if (locked) footer = overrideLockMsg ?? cfg("lockMessage"); - else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`; - embed.setFooter({ text: footer }); - - return embed; -} export function buildButtons( disabled: boolean, @@ -248,7 +109,7 @@ export async function updatePollMessage( console.log(`[updatePollMessage] components rows=${buttons.length}`); try { const msg = await channel.messages.fetch(state.messageId); - await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buttons }); + await msg.edit({ embeds: [PollUI.buildEmbed(state, { overrideLockMsg })], components: buttons }); } catch (err) { console.error("Failed to update poll message:", err); } @@ -267,7 +128,7 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise(); +let _activeLayout: PollLayout; + +export function registerLayout(layout: PollLayout): void { + _layouts.set(layout.name, layout); + if (!_activeLayout) _activeLayout = layout; // first registered = default +} + +function isPollLayout(obj: any): obj is PollLayout { + return obj?.name && + obj?.description && + typeof obj?.buildEmbed === "function" && + typeof obj?.formatRow === "function" && + typeof obj?.buildContext === "function"; +} + +export function discoverLayouts(): void { + const layoutsDir = path.join(__dirname, "layouts"); + if (!fs.existsSync(layoutsDir)) return; + + const files = fs.readdirSync(layoutsDir) + .filter((f) => f.endsWith(".ts") || f.endsWith(".js")) + .sort(); // consistent order — default.ts loads before side-by-side.ts + + for (const file of files) { + try { + const mod = require(path.join(layoutsDir, file)); + for (const exported of Object.values(mod)) { + if (isPollLayout(exported)) { + registerLayout(exported); + console.log(`[PollUI] Registered layout: ${(exported as PollLayout).name}`); + } + } + } catch (err) { + console.error(`[PollUI] Failed to load layout ${file}:`, err); + } + } +} + +function restoreLayout() { + const savedLayout = Config.get("pollLayout"); + + if (savedLayout && _layouts.has(savedLayout)) { + _activeLayout = _layouts.get(savedLayout)!; + console.log(`[PollUI] Restored layout: ${savedLayout}`); + } +} + +// Auto-discover at module load time +Config.load(); +discoverLayouts(); +restoreLayout(); + +// ─── Dispatcher ─────────────────────────────────────────────────────────────── + +function activeLayout(): PollLayout { + return _activeLayout; +} + +export const PollUI = { + buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder { + return activeLayout().buildEmbed(state, options); + }, + + formatRow(entry: VoteEntry, context: PollRowContext): string { + return activeLayout().formatRow(entry, context); + }, + + buildContext( + entries: VoteEntry[], + nation: Nation, + options?: { showNationEmoji?: boolean } + ): PollRowContext { + return activeLayout().buildContext(entries, nation, options); + }, + + setLayout(name: string): boolean { + const layout = _layouts.get(name); + if (!layout) return false; + _activeLayout = layout; + return true; + }, + + layouts(): { name: string; description: string }[] { + return [..._layouts.values()].map((l) => ({ + name: l.name, + description: l.description, + })); + }, + + register: registerLayout, +}; \ No newline at end of file diff --git a/src/ui/poll/layouts/default.ts b/src/ui/poll/layouts/default.ts new file mode 100644 index 0000000..106f0b2 --- /dev/null +++ b/src/ui/poll/layouts/default.ts @@ -0,0 +1,204 @@ +/** + * Default poll layout — vertical, nation-separated fields. + * This is the standard layout and always the fallback. + */ + + import { EmbedBuilder } from "discord.js"; + import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; + import { cfg } from "@systems/config"; + import { WRank } from "@systems/wrank"; + import { Bringer } from "@systems/bringer"; + import { Emoji } from "@systems/emojis"; + import { format } from "@format"; + import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; + + // ─── Row formatting ─────────────────────────────────────────────────────────── + + function formatWRank( + wRankEntry: WRankEntry | null, + context: PollRowContext + ): string { + if (wRankEntry) { + const goal = cfg("wRankGoal"); + return format.wrank.full(wRankEntry, { goal, brackets: true }); + } + if (!context.nationHasRank) return ""; + if (context.nationHasDelta) return format.wrank.noRank(); + return "—"; + } + + function formatRow(entry: VoteEntry, context: PollRowContext): string { + const cfgFormat = cfg("charDisplayFormat"); + const nation = entry.characterNation; + + const wRankEntry = entry.characterName && entry.characterNation + ? WRank.entry(entry.characterName, entry.characterNation) + : null; + + const wrank = formatWRank(wRankEntry, context); + const classStr = entry.characterClass + ? (Emoji.class(entry.characterClass) || entry.characterClass) + : ""; + const levelStr = entry.characterLevel ? `${entry.characterLevel}` : ""; + + let row = cfgFormat + .replace("{wrank}", wrank) + .replace("{class}", classStr) + .replace("{level}", levelStr) + .replace("{name}", entry.characterName ?? entry.displayName ?? "") + .replace(/\s+/g, " ") + .trim(); + + if (nation && entry.userKey) { + const bringer = Bringer.get({ nation }); + if (bringer && bringer === entry.characterName) { + row += ` · ${format.bringer(nation)}`; + } + } + + if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`; + if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; + + return row; + } + + function buildContext( + entries: VoteEntry[], + nation: Nation, + options?: { showNationEmoji?: boolean } + ): PollRowContext { + const nationHasRank = entries.some((e) => + e.characterName && WRank.entry(e.characterName, nation) !== null + ); + const nationHasDelta = entries.some((e) => { + const wr = e.characterName ? WRank.entry(e.characterName, nation) : null; + return wr?.previousRank !== undefined; + }); + return { + nationHasRank, + nationHasDelta, + showNationEmoji: options?.showNationEmoji ?? false, + }; + } + + // ─── Embed building ─────────────────────────────────────────────────────────── + + function formatNationField( + nation: Nation, + yesEntries: VoteEntry[], + noVoters: VoteEntry[], + showNoInline: boolean + ): string { + const context = buildContext(yesEntries, nation); + const noEntries = showNoInline + ? noVoters.filter((e) => e.characterNation === nation) + : []; + const lines = [ + ...yesEntries.map((e) => formatRow(e, context)), + ...noEntries.map((e) => `❌ ${formatRow(e, context)}`), + ]; + return lines.length > 0 ? lines.join("\n") : "—"; + } + + function formatMessages( + allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] + ): string { + if (allMessages.length === 0) return ""; + return allMessages + .map((m) => { + const name = m.entry.characterName ?? m.entry.displayName; + const prefix = m.voteType === "no" ? "✗ " : "✓ "; + const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : ""; + return `${prefix}${name} · ${m.entry.votedAt}${msg}`; + }) + .join("\n"); + } + + function resolveColor(state: PollState): number { + if (state.confirmed === "yes") return 0x57f287; + if (state.confirmed === "no") return 0xed4245; + if (state.locked) return 0x888888; + return 0xe8a317; + } + + function resolveTitle( + state: PollState, + yesByNation: Record + ): string { + const capellaEmoji = Emoji.get("capella"); + const procyonEmoji = Emoji.get("procyon"); + const counts = !state.locked && state.confirmed === null + ? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}` + : ""; + const statusSuffix = + state.locked ? " 🔒" : + state.confirmed === "yes" ? " ✅" : + state.confirmed === "no" ? " ❌" : ""; + return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; + } + + function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string { + if (state.confirmed === "yes") return cfg("confirmYesMessage"); + if (state.confirmed === "no") return cfg("confirmNoMessage"); + if (state.locked) return overrideLockMsg ?? cfg("lockMessage"); + return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; + } + + function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder { + const yesByNation: Record = { + [Nation.Capella]: [], + [Nation.Procyon]: [], + }; + const noVoters: VoteEntry[] = []; + const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; + const showNoInline = (cfg as any)("showNoInNationField") ?? false; + + for (const entry of state.yes.values()) { + const nation = entry.characterNation ?? Nation.Capella; + yesByNation[nation].push(entry); + allMessages.push({ entry, voteType: "yes" }); + } + for (const entry of state.no.values()) { + noVoters.push(entry); + allMessages.push({ entry, voteType: "no" }); + } + + const capellaEmoji = Emoji.get("capella"); + const procyonEmoji = Emoji.get("procyon"); + + const embed = new EmbedBuilder() + .setTitle(resolveTitle(state, yesByNation)) + .setColor(resolveColor(state)) + .addFields( + { + name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, + value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline), + inline: false, + }, + { name: "\u200b", value: "\u200b", inline: false }, + { + name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, + value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline), + inline: false, + }, + ) + .setTimestamp(); + + const msgSection = formatMessages(allMessages); + if (msgSection) { + embed.addFields({ name: "\u200b", value: msgSection, inline: false }); + } + + embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) }); + return embed; + } + + // ─── Layout export ──────────────────────────────────────────────────────────── + + export const defaultLayout: PollLayout = { + name: "default", + description: "Standard vertical layout with nation-separated fields", + buildEmbed, + formatRow, + buildContext, + }; \ No newline at end of file diff --git a/src/ui/poll/layouts/side-by-side.ts b/src/ui/poll/layouts/side-by-side.ts new file mode 100644 index 0000000..bfe94b0 --- /dev/null +++ b/src/ui/poll/layouts/side-by-side.ts @@ -0,0 +1,178 @@ +/** + * Side-by-side poll layout — Capella and Procyon displayed as inline fields. + * Nations appear next to each other rather than stacked vertically. + */ + + import { EmbedBuilder } from "discord.js"; + import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; + import { cfg } from "@systems/config"; + import { WRank } from "@systems/wrank"; + import { Bringer } from "@systems/bringer"; + import { Emoji } from "@systems/emojis"; + import { format } from "@format"; + import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; + + // ─── Row formatting (same as default) ──────────────────────────────────────── + + function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { + if (wRankEntry) { + return format.wrank.full(wRankEntry, { goal: cfg("wRankGoal"), brackets: true }); + } + if (!context.nationHasRank) return ""; + if (context.nationHasDelta) return format.wrank.noRank(); + return "—"; + } + + function formatRow(entry: VoteEntry, context: PollRowContext): string { + const cfgFormat = cfg("charDisplayFormat"); + const nation = entry.characterNation; + const wRankEntry = entry.characterName && entry.characterNation + ? WRank.entry(entry.characterName, entry.characterNation) + : null; + + const wrank = formatWRank(wRankEntry, context); + const classStr = entry.characterClass ? (Emoji.class(entry.characterClass) || entry.characterClass) : ""; + const levelStr = entry.characterLevel ? `${entry.characterLevel}` : ""; + + let row = cfgFormat + .replace("{wrank}", wrank) + .replace("{class}", classStr) + .replace("{level}", levelStr) + .replace("{name}", entry.characterName ?? entry.displayName ?? "") + .replace(/\s+/g, " ") + .trim(); + + if (nation && entry.userKey) { + const bringer = Bringer.get({ nation }); + if (bringer && bringer === entry.characterName) { + row += ` · ${format.bringer(nation)}`; + } + } + + if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`; + if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; + return row; + } + + function buildContext( + entries: VoteEntry[], + nation: Nation, + options?: { showNationEmoji?: boolean } + ): PollRowContext { + const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null); + const nationHasDelta = entries.some((e) => { + const wr = e.characterName ? WRank.entry(e.characterName, nation) : null; + return wr?.previousRank !== undefined; + }); + return { nationHasRank, nationHasDelta, showNationEmoji: options?.showNationEmoji ?? false }; + } + + // ─── Embed building ─────────────────────────────────────────────────────────── + + function formatNationField( + nation: Nation, + yesEntries: VoteEntry[], + noVoters: VoteEntry[], + showNoInline: boolean + ): string { + const context = buildContext(yesEntries, nation); + const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : []; + const lines = [ + ...yesEntries.map((e) => formatRow(e, context)), + ...noEntries.map((e) => `❌ ${formatRow(e, context)}`), + ]; + return lines.length > 0 ? lines.join("\n") : "—"; + } + + function formatMessages(allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]): string { + if (allMessages.length === 0) return ""; + return allMessages + .map((m) => { + const name = m.entry.characterName ?? m.entry.displayName; + const prefix = m.voteType === "no" ? "✗ " : "✓ "; + const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : ""; + return `${prefix}${name} · ${m.entry.votedAt}${msg}`; + }) + .join("\n"); + } + + function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder { + const yesByNation: Record = { + [Nation.Capella]: [], + [Nation.Procyon]: [], + }; + const noVoters: VoteEntry[] = []; + const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; + const showNoInline = (cfg as any)("showNoInNationField") ?? false; + + for (const entry of state.yes.values()) { + const nation = entry.characterNation ?? Nation.Capella; + yesByNation[nation].push(entry); + allMessages.push({ entry, voteType: "yes" }); + } + for (const entry of state.no.values()) { + noVoters.push(entry); + allMessages.push({ entry, voteType: "no" }); + } + + const capellaEmoji = Emoji.get("capella"); + const procyonEmoji = Emoji.get("procyon"); + + // Title + const counts = !state.locked && state.confirmed === null + ? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}` + : ""; + const statusSuffix = + state.locked ? " 🔒" : + state.confirmed === "yes" ? " ✅" : + state.confirmed === "no" ? " ❌" : ""; + + const color = + state.confirmed === "yes" ? 0x57f287 : + state.confirmed === "no" ? 0xed4245 : + state.locked ? 0x888888 : + 0xe8a317; + + const embed = new EmbedBuilder() + .setTitle(`⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`) + .setColor(color) + .addFields( + // ← inline: true makes them side by side + { + name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, + value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline), + inline: true, + }, + { + name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, + value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline), + inline: true, + }, + ) + .setTimestamp(); + + const msgSection = formatMessages(allMessages); + if (msgSection) { + embed.addFields({ name: "\u200b", value: msgSection, inline: false }); + } + + // Footer + let footer: string; + if (state.confirmed === "yes") footer = cfg("confirmYesMessage"); + else if (state.confirmed === "no") footer = cfg("confirmNoMessage"); + else if (state.locked) footer = options?.overrideLockMsg ?? cfg("lockMessage"); + else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`; + embed.setFooter({ text: footer }); + + return embed; + } + + // ─── Layout export ──────────────────────────────────────────────────────────── + + export const sideBySideLayout: PollLayout = { + name: "side-by-side", + description: "Nations displayed as inline fields side by side", + buildEmbed, + formatRow, + buildContext, + }; \ No newline at end of file diff --git a/src/ui/types.ts b/src/ui/types.ts new file mode 100644 index 0000000..77eda5c --- /dev/null +++ b/src/ui/types.ts @@ -0,0 +1,27 @@ +/** + * UI types — shared interfaces for all UI modules. + */ + + import { EmbedBuilder } from "discord.js"; + import { PollState, VoteEntry, Nation } from "@types"; + + // ─── Poll ───────────────────────────────────────────────────────────────────── + + export interface PollRowContext { + nationHasRank: boolean; + nationHasDelta: boolean; + showNationEmoji?: boolean; + } + + export interface PollEmbedOptions { + overrideLockMsg?: string; + showScoreButton?: boolean; + } + + export interface PollLayout { + name: string; + description: string; + buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder; + formatRow(entry: VoteEntry, context: PollRowContext): string; + buildContext(entries: VoteEntry[], nation: Nation, options?: { showNationEmoji?: boolean }): PollRowContext; + } \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index e45af70..e2e0520 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,10 +1,11 @@ import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; +import { Config } from "@systems/config"; // Poll vote confirmation messages (Yes/No button responses) -const POLL_EPHEMERAL_ENABLED = process.env.POLL_EPHEMERAL_ENABLED !== "false"; +const POLL_EPHEMERAL_ENABLED = Config.get("pollEphemeralEnabled"); // Command output messages (score, rank, status etc.) — always on by default -const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false"; -const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); +const COMMAND_EPHEMERAL_ENABLED = Config.get("commandEphemeralEnabled"); +const EPHEMERAL_DELETE_MS = Config.get("ephemeralDeleteMs"); // For poll button responses export async function pollReplyAndDelete( diff --git a/tsconfig.json b/tsconfig.json index 0aecf5c..1b0c580 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,8 +33,12 @@ "@paths": ["src/helpers/paths"], "@characters": ["src/systems/characters"], "@systems/scheduler": ["src/systems/scheduler/index"], + "@ui": ["src/ui/index"], + "@ui/*": ["src/ui/*"], + "@ui/poll": ["src/ui/poll/index"], + "@ui/types": ["src/ui/types"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "exclude": ["node_modules", "dist"] } \ No newline at end of file