From d2377ff40405667d520f7c224d24f73463ca8c2b Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 11 Jun 2026 23:53:06 +0100 Subject: [PATCH] feat: BaseLayout shared functions, WRank delta placeholder fix, Leaves system --- src/systems/format.ts | 25 ++- src/systems/scheduler.tsx | 115 ----------- src/ui/poll/base-layout.ts | 216 +++++++++++++++++++ src/ui/poll/layouts.bak/default.ts | 212 +++++++++++++++++++ src/ui/poll/layouts.bak/side-by-side.ts | 197 ++++++++++++++++++ src/ui/poll/layouts/default.ts | 262 +++++------------------- src/ui/poll/layouts/side-by-side.ts | 246 +++++----------------- 7 files changed, 748 insertions(+), 525 deletions(-) delete mode 100644 src/systems/scheduler.tsx create mode 100644 src/ui/poll/base-layout.ts create mode 100644 src/ui/poll/layouts.bak/default.ts create mode 100644 src/ui/poll/layouts.bak/side-by-side.ts diff --git a/src/systems/format.ts b/src/systems/format.ts index 529ecb8..42fa7fd 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -80,12 +80,20 @@ function wrankRank(entry: WRankEntry, goal: number): string { * 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 { +function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean, placeholder?: 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 ""; + if (delta === 0 && prev === undefined) { + // No previous rank — show grey placeholder if requested + if (options?.placeholder) { + const dash = Emoji.get("wrank_no_dash") || "—"; + const zero = Emoji.get("wrank_no_rank_delta") || "0"; + return brackets ? ` (${dash}${zero})` : ` ${dash}${zero}`; + } + return ""; + } let inner: string; if (delta < 0) { @@ -112,13 +120,14 @@ function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string { /** * Placeholder for characters with no W.Rank when others in their nation have one. - * Output: — ( — 0 ) + * delta: true => Output: — ( — 0 ); + * delta: false => Output: — */ - function wrankNoRank(): string { - const norank = Emoji.get("wrank_no_dash") || "—"; - const dash = Emoji.get("wrank_no_rank_delta") || "—"; - const square = Emoji.get("wrank_no_dash") || "■"; - return `${norank} (${square}${dash})`; + function wrankNoRank({ delta = false }: { delta?: boolean } = {}): string { + const dash = Emoji.get("wrank_no_dash") || "—"; + if (!delta) return dash; // just "—" when no delta context + const zero = Emoji.get("wrank_no_rank_delta") || "0"; + return `${dash} (${dash}${zero})`; // "— ( — 0 )" when others have delta } // ─── Bringer formatters ──────────────────────────────────────────────────────── diff --git a/src/systems/scheduler.tsx b/src/systems/scheduler.tsx deleted file mode 100644 index 67c020e..0000000 --- a/src/systems/scheduler.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Scheduler — manages all cron jobs for the bot. - * - * Usage: - * import { Scheduler } from "@systems/scheduler"; - * Scheduler.schedule(client); - * Scheduler.reschedule(client); // after slot config changes - */ - - import cron from "node-cron"; - import { Client, TextChannel } from "discord.js"; - import { Config } from "@systems/config"; - import { TGSlot } from "@types"; - import { polls, updatePollMessage } from "@systems/poll"; - import { WRank } from "@systems/wrank"; - import { Nation } from "@types"; - - type PollCallback = (slot: TGSlot) => Promise; - type LockCallback = (slot: TGSlot) => Promise; - type CloseCallback = (slot: TGSlot) => Promise; - - let _tasks: cron.ScheduledTask[] = []; - - function stopAll(): void { - _tasks.forEach((t) => t.stop()); - _tasks = []; - } - - export const Scheduler = { - /** - * Schedule all cron jobs — slot polls, weekly reset. - * Call once on bot startup, and again after slot config changes. - */ - schedule( - client: Client, - onPollOpen: PollCallback, - onPollLock: LockCallback, - onPollClose: CloseCallback, - ): void { - stopAll(); - - const tz = process.env.TZ ?? "Etc/GMT-2"; - const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); - - for (const slot of slots) { - // Poll open - const [openHour, openMin] = slot.pollOpens.split(":").map(Number); - _tasks.push(cron.schedule( - `${openMin} ${openHour} * * *`, - () => onPollOpen(slot), - { timezone: tz } - )); - - // Poll lock — at tgHour (TG start, voting closes, attendance snapshotted) - _tasks.push(cron.schedule( - `0 ${slot.tgHour} * * *`, - () => onPollLock(slot), - { timezone: tz } - )); - - // Poll close — tgHour + closesAfter minutes (TG end, Submit Score button appears) - const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; - const closeHour = Math.floor(closeMinTotal / 60) % 24; - const closeMin = closeMinTotal % 60; - _tasks.push(cron.schedule( - `${closeMin} ${closeHour} * * *`, - () => onPollClose(slot), - { timezone: tz } - )); - - // Midnight cleanup — remove Submit Score button if poll is still showing it - _tasks.push(cron.schedule("0 0 * * *", async () => { - const state = polls.get(slot.tgHour); - if (!state?.locked) return; - try { - const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; - await updatePollMessage(channel, slot.tgHour, undefined, false); - console.log(`[Scheduler] Submit Score button removed for ${slot.tgHour}:00`); - } catch (err) { - console.error(`[Scheduler] Failed to remove Submit Score button:`, err); - } - }, { timezone: tz })); - } - - // Weekly reset — Monday 00:00 - _tasks.push(cron.schedule("0 0 * * 1", () => { - console.log(`[Scheduler] Weekly W.Rank reset starting...`); - WRank.resetWeek(); - console.log(`[Scheduler] Weekly W.Rank reset complete.`); - }, { timezone: tz })); - - console.log(`[Scheduler] Scheduled ${slots.length} slot(s) + weekly reset.`); - }, - - /** - * Reschedule all jobs — call after slot config changes. - */ - reschedule( - client: Client, - onPollOpen: PollCallback, - onPollLock: LockCallback, - onPollClose: CloseCallback, - ): void { - console.log(`[Scheduler] Rescheduling...`); - Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose); - }, - - /** - * Stop all scheduled jobs. - */ - stop(): void { - stopAll(); - console.log(`[Scheduler] All jobs stopped.`); - }, - }; \ No newline at end of file diff --git a/src/ui/poll/base-layout.ts b/src/ui/poll/base-layout.ts new file mode 100644 index 0000000..55d8b0b --- /dev/null +++ b/src/ui/poll/base-layout.ts @@ -0,0 +1,216 @@ +/** + * BaseLayout — shared poll layout functions. + * All layouts inherit these via BaseLayout.methods() spread. + * Override only what differs in each layout. + * + * Usage: + * export const myLayout: PollLayout = { + * ...BaseLayout.methods(), + * name: "my-layout", + * description: "...", + * buildEmbed(state, options) { ... }, // override + * }; + */ + + import { VoteEntry, Nation } from "@types"; + import { Config } from "@systems/config"; + import { WRank, WRankEntry } from "@systems/wrank"; + import { Bringer } from "@systems/bringer"; + import { Leaves } from "@systems/leaves"; + import { Emoji } from "@systems/emojis"; + import { format } from "@format"; + import { PollRowContext } from "@ui/types"; + + // ─── W.Rank formatting ──────────────────────────────────────────────────────── + + export function formatWRank( + wRankEntry: WRankEntry | null, + context: PollRowContext + ): string { + if (!wRankEntry || wRankEntry.currentRank === 0) { + if (!context.nationHasRank) return ""; + return format.wrank.noRank({ delta: context.nationHasDelta }); + } + + const goal = Config.get({ section: "wrank", key: "goal" }); + const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta; + return format.wrank.rank(wRankEntry, goal) + + format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder }); + } + + // ─── Row formatting ─────────────────────────────────────────────────────────── + + export function formatRow(entry: VoteEntry, context: PollRowContext): string { + const cfgFormat = Config.get({ section: "poll", key: "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(); + + // Bringer indicator + if (nation && entry.userKey) { + const bringer = Bringer.get({ nation }); + if (bringer && bringer === entry.characterName) { + row += ` · ${format.bringer(nation)}`; + } + } + + // Shared character indicator + if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`; + + // Nation emoji prefix + if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; + + // Cockroach indicator (left TG) + if (entry.userKey && entry.characterName && context.historyKey) { + if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { + row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; + } + } + + return row; + } + + // ─── Context building ───────────────────────────────────────────────────────── + + export function buildContext( + entries: VoteEntry[], + nation: Nation, + options?: { showNationEmoji?: boolean; historyKey?: string } + ): PollRowContext { + const nationHasRank = entries.some((e) => { + if (!e.characterName) return false; + const wr = WRank.entry(e.characterName, nation); + return wr !== null && wr.currentRank !== 0; + }); + + const nationHasDelta = entries.some((e) => { + if (!e.characterName) return false; + const wr = WRank.entry(e.characterName, nation); + return wr?.previousRank !== undefined; + }); + + return { + nationHasRank, + nationHasDelta, + showNationEmoji: options?.showNationEmoji ?? false, + historyKey: options?.historyKey, + }; + } + + // ─── Message formatting ─────────────────────────────────────────────────────── + + export 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"); + } + + // ─── Embed helpers ──────────────────────────────────────────────────────────── + + export function formatNationField( + nation: Nation, + yesEntries: VoteEntry[], + noVoters: VoteEntry[], + showNoInline: boolean, + historyKey?: string + ): string { + const context = buildContext(yesEntries, nation, { historyKey }); + 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") : "—"; + } + + export function resolveColor(state: any): number { + if (state.confirmed === "yes") return 0x57f287; + if (state.confirmed === "no") return 0xed4245; + if (state.locked) return 0x888888; + return 0xe8a317; + } + + export function resolveTitle(state: any, 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}`; + } + + export function resolveFooter(state: any, noCount: number, overrideLockMsg?: string): string { + if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" }); + if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" }); + if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); + return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; + } + + export function buildYesByNation(state: any): { + yesByNation: Record; + noVoters: VoteEntry[]; + allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]; + } { + const yesByNation: Record = { + [Nation.Capella]: [], + [Nation.Procyon]: [], + }; + const noVoters: VoteEntry[] = []; + const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; + + for (const entry of state.yes.values()) { + const nation: Nation = entry.characterNation === Nation.Procyon + ? Nation.Procyon + : 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" }); + } + + return { yesByNation, noVoters, allMessages }; + } + + // ─── BaseLayout factory ─────────────────────────────────────────────────────── + + export const BaseLayout = { + methods() { + return { + formatRow, + buildContext, + }; + }, + }; \ No newline at end of file diff --git a/src/ui/poll/layouts.bak/default.ts b/src/ui/poll/layouts.bak/default.ts new file mode 100644 index 0000000..f9bc062 --- /dev/null +++ b/src/ui/poll/layouts.bak/default.ts @@ -0,0 +1,212 @@ +/** + * 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 } from "@types"; + import { WRankEntry } from "@systems/wrank"; + import { Config } 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"; + import { Leaves } from "@systems/leaves"; + + // ─── Row formatting ─────────────────────────────────────────────────────────── + + function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { + if (!wRankEntry || wRankEntry.currentRank === 0) { + if (!context.nationHasRank) return ""; + return format.wrank.noRank({ delta: context.nationHasDelta }); + } + + const goal = Config.get({ section: "wrank", key: "goal" }); + const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta; + return format.wrank.rank(wRankEntry, goal) + + format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder }); +} + + function formatRow(entry: VoteEntry, context: PollRowContext): string { + const cfgFormat = Config.get({ section: "poll", key: "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.userKey && context.historyKey) { + if (Leaves.hasLeft({ characterName: entry.characterName!, historyKey: context.historyKey })) { + row += ` ${Leaves.formatIndicator({ characterName: entry.characterName! })}`; + } + } + + 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; historyKey?: string } + ): 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, + historyKey: options?.historyKey, + }; + } + + // ─── 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 Config.get({ section: "poll", key: "confirmYes" }); + if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" }); + if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "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 = Config.get({ section: "poll", key: "showNoInNationField" }); + + 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.bak/side-by-side.ts b/src/ui/poll/layouts.bak/side-by-side.ts new file mode 100644 index 0000000..c6c56f3 --- /dev/null +++ b/src/ui/poll/layouts.bak/side-by-side.ts @@ -0,0 +1,197 @@ +/** + * 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 } from "@types"; + import { WRankEntry } from "@systems/wrank"; + import { Config } 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"; + import { Leaves } from "@systems/leaves"; + + // ─── Row formatting (same as default) ──────────────────────────────────────── + + function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { + if (!wRankEntry || wRankEntry.currentRank === 0) { + if (!context.nationHasRank) return ""; + return format.wrank.noRank({ delta: context.nationHasDelta }); + } + + const goal = Config.get({ section: "wrank", key: "goal" }); + const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta; + return format.wrank.rank(wRankEntry, goal) + + format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder }); +} + + function formatRow(entry: VoteEntry, context: PollRowContext): string { + const cfgFormat = Config.get({ section: "poll", key: "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.userKey && entry.characterName && context.historyKey) { + if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { + row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; + } + } + + 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; historyKey?: string } + ): 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, + historyKey: options?.historyKey, + }; + } + + // ─── Embed building ─────────────────────────────────────────────────────────── + + function formatNationField( + nation: Nation, + yesEntries: VoteEntry[], + noVoters: VoteEntry[], + showNoInline: boolean, + historyKey?: string + ): string { + const context = buildContext(yesEntries, nation, { historyKey }); + 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 = Config.get({ section: "poll", key: "showNoInNationField" }); + + 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 historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`; + + // 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, historyKey), + inline: true, + }, + { + name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, + value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey), + 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 = Config.get({ section: "poll", key: "confirmYes" }); + else if (state.confirmed === "no") footer = Config.get({ section: "poll", key: "confirmNo" }); + else if (state.locked) footer = options?.overrideLockMsg ?? Config.get({ section: "poll", key: "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/poll/layouts/default.ts b/src/ui/poll/layouts/default.ts index 2fbabad..a9036da 100644 --- a/src/ui/poll/layouts/default.ts +++ b/src/ui/poll/layouts/default.ts @@ -1,213 +1,53 @@ -/** - * Default poll layout — vertical, nation-separated fields. - * This is the standard layout and always the fallback. - */ +import { EmbedBuilder } from "discord.js"; +import { PollState, Nation, VoteEntry } from "@types"; +import { Emoji } from "@systems/emojis"; +import { PollLayout, PollEmbedOptions } from "@ui/types"; +import { + BaseLayout, + buildYesByNation, + formatNationField, + formatMessages, + resolveColor, + resolveTitle, + resolveFooter, +} from "../base-layout"; - import { EmbedBuilder } from "discord.js"; - import { PollState, VoteEntry, Nation } from "@types"; - import { WRankEntry } from "@systems/wrank"; - import { Config } 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"; - import { Leaves } from "@systems/leaves"; - - // ─── Row formatting ─────────────────────────────────────────────────────────── - - function formatWRank( - wRankEntry: WRankEntry | null, - context: PollRowContext - ): string { - if (wRankEntry) { - const goal = Config.get({ section: "wrank", key: "goal" }); - 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 = Config.get({ section: "poll", key: "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)}`; - } - } +function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder { + const { yesByNation, noVoters, allMessages } = buildYesByNation(state); + const showNoInline = false; // default layout stacks no-voters + const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`; - if (entry.userKey && context.historyKey) { - if (Leaves.hasLeft({ userKey: entry.userKey, historyKey: context.historyKey })) { - row += ` ${Leaves.formatIndicator({ userKey: entry.userKey })}`; - } - } - - 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; historyKey?: string } - ): 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, - historyKey: options?.historyKey, - }; - } - - // ─── 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 Config.get({ section: "poll", key: "confirmYes" }); - if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" }); - if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "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 = Config.get({ section: "poll", key: "showNoInNationField" }); - - 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 + 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, historyKey), + inline: false, + }, + { name: "\u200b", value: "\u200b", inline: false }, + { + name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, + value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey), + 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; +} + +export const defaultLayout: PollLayout = { + ...BaseLayout.methods(), + name: "default", + description: "Standard vertical layout with nation-separated fields", + buildEmbed, +}; \ 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 index ebb44f1..7cc9a6a 100644 --- a/src/ui/poll/layouts/side-by-side.ts +++ b/src/ui/poll/layouts/side-by-side.ts @@ -1,194 +1,58 @@ -/** - * 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, Nation } from "@types"; +import { Emoji } from "@systems/emojis"; +import { PollLayout, PollEmbedOptions } from "@ui/types"; +import { + BaseLayout, + buildYesByNation, + formatNationField, + formatMessages, + resolveColor, + resolveTitle, + resolveFooter, +} from "../base-layout"; - import { EmbedBuilder } from "discord.js"; - import { PollState, VoteEntry, Nation } from "@types"; - import { WRankEntry } from "@systems/wrank"; - import { Config } 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"; - import { Leaves } from "@systems/leaves"; - - // ─── Row formatting (same as default) ──────────────────────────────────────── - - function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { - if (wRankEntry) { - return format.wrank.full(wRankEntry, { goal: Config.get({ section: "wrank", key: "goal" }), brackets: true }); - } - if (!context.nationHasRank) return ""; - if (context.nationHasDelta) return format.wrank.noRank(); - return "—"; - } - - function formatRow(entry: VoteEntry, context: PollRowContext): string { - const cfgFormat = Config.get({ section: "poll", key: "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)}`; - } - } +function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder { + const { yesByNation, noVoters, allMessages } = buildYesByNation(state); + const showNoInline = false; + const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`; - if (entry.userKey && entry.characterName && context.historyKey) { - if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { - row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; - } - } - - 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; historyKey?: string } - ): 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, - historyKey: options?.historyKey, - }; - } - - // ─── Embed building ─────────────────────────────────────────────────────────── - - function formatNationField( - nation: Nation, - yesEntries: VoteEntry[], - noVoters: VoteEntry[], - showNoInline: boolean, - historyKey?: string - ): string { - const context = buildContext(yesEntries, nation, { historyKey }); - 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 = Config.get({ section: "poll", key: "showNoInNationField" }); - - 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 historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`; + 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, historyKey), - inline: true, - }, - { - name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, - value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey), - 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 = Config.get({ section: "poll", key: "confirmYes" }); - else if (state.confirmed === "no") footer = Config.get({ section: "poll", key: "confirmNo" }); - else if (state.locked) footer = options?.overrideLockMsg ?? Config.get({ section: "poll", key: "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 + const maxPlayers = Math.max( + yesByNation[Nation.Capella].length, + yesByNation[Nation.Procyon].length + ); + const useInline = maxPlayers <= 5; + + 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, historyKey), + inline: useInline, + }, + { + name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, + value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey), + inline: useInline, + }, + ) + .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; +} + +export const sideBySideLayout: PollLayout = { + ...BaseLayout.methods(), + name: "side-by-side", + description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)", + buildEmbed, +}; \ No newline at end of file