feat: UI layout system, Config namespace, Bootstrap phases design, Logger/Benchmark

This commit is contained in:
Nuno Duque Nunes 2026-06-10 04:20:03 +01:00
parent 3c4aed93df
commit 2cde01e633
20 changed files with 781 additions and 227 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, { yes: number; no: number }>();
const _processingVotes = new Set<string>();
@ -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}`,

View file

@ -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<void> {
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.");

View file

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

View file

@ -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 = {
/**

View file

@ -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<BotConfig> {
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 {
// ─── Config namespace ─────────────────────────────────────────────────────────
export const Config = {
load(): void {
_cfg = Store.readOrDefault(Paths.data("config.json"), {});
}
export function saveConfig(): void {
},
save(): void {
Store.write(Paths.data("config.json"), _cfg);
}
},
export function cfg<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {
reload(): void {
Config.load();
},
get<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {
return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required<BotConfig>[K];
}
},
export function setCfg<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void {
set<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void {
_cfg[key] = value;
saveConfig();
}
Config.save();
},
export function resetCfg<K extends keyof BotConfig>(key: K): void {
reset<K extends keyof BotConfig>(key: K): void {
delete _cfg[key];
saveConfig();
}
Config.save();
},
all(): Required<BotConfig> {
return { ...getDefaults(), ..._cfg };
},
};
// ─── Legacy aliases — remove after full migration ────────────────────────────
export const cfg = <K extends keyof BotConfig>(key: K) => Config.get(key);
export const setCfg = <K extends keyof BotConfig>(key: K, value: BotConfig[K]) => Config.set(key, value);
export const resetCfg = <K extends keyof BotConfig>(key: K) => Config.reset(key);
export const loadConfig = () => Config.load();
export const saveConfig = () => Config.save();

View file

@ -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<string, {

View file

@ -1,8 +1,9 @@
import { UserRegistry } from "@registry/user-registry";
import { UsermapEntry } from "@types";
import { Config } from "@systems/config";
const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false";
const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false";
const IMPERSONATE_RESET_ON_POLL = Config.get("impersonateResetOnPoll");
const IMPERSONATE_INDICATOR = Config.get("impersonateIndicator");
// realDiscordId → userKey being impersonated
const impersonations = new Map<string, string>();

View file

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

View file

@ -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<void
locked: false, confirmed: null,
};
polls.set(slot.tgHour, state);
const msg = await channel.send({ embeds: [buildEmbed(state)], components: buildButtons(false) });
const msg = await channel.send({ embeds: [PollUI.buildEmbed(state)], components: buildButtons(false) });
state.messageId = msg.id;
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);

View file

@ -307,39 +307,6 @@ export interface BringerState {
procyonOverride?: string;
}
// ─── Config ──────────────────────────────────────────────────────────────────
export interface BotConfig {
officerRoles?: string[];
configRoles?: string[];
tagRoles?: string[];
lockMessage?: string;
confirmYesMessage?: string;
confirmNoMessage?: string;
pollChannelId?: string;
resultsChannelId?: string;
scoreChannelId?: string;
slots?: TGSlot[];
scoreWindowHours?: number;
tgDurationMinutes?: number;
nationSource?: Nation;
wRankPostOnReset?: boolean;
wRankGoal?: number; // default 7
wRankYellowColor?: string; // hex
wRankGrayColor?: string; // hex
deltaUpColor?: string; // hex
deltaDownColor?: string; // hex
stormBringerColor?: string; // hex
luminousBringerColor?: string; // hex
showClassInMessages?: boolean;
showLevelInMessages?: boolean;
charDisplayFormat?: string; // "{wrank} {class} {name}"
showNationTotalsInHeader?: boolean;
showNoInNationField?: boolean;
borrowRequestExpiryMs?: number; // 0 = never expire (default)
conflictReclaimBehavior?: "revert" | "remove"
}
// ─── Messages ────────────────────────────────────────────────────────────────
export interface MessageEntry {

8
src/ui/index.ts Normal file
View file

@ -0,0 +1,8 @@
import { PollUI } from "./poll";
export const UI = {
Poll: PollUI,
};
export { PollUI };
export type { PollLayout, PollRowContext, PollEmbedOptions } from "./types";

101
src/ui/poll/index.ts Normal file
View file

@ -0,0 +1,101 @@
import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation } from "@types";
import { Config } from "@systems/config";
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
import path from "path";
import fs from "fs";
// ─── Layout registry ──────────────────────────────────────────────────────────
const _layouts = new Map<string, PollLayout>();
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,
};

View file

@ -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<Nation, VoteEntry[]>
): 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, VoteEntry[]> = {
[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,
};

View file

@ -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, VoteEntry[]> = {
[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,
};

27
src/ui/types.ts Normal file
View file

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

View file

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

View file

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