/** * BaseLayout — shared poll layout functions. * All layouts inherit these via BaseLayout.methods() spread. * Override only what differs in each layout. */ import { EmbedBuilder } from "discord.js"; import { VoteEntry, Nation, PollState } 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 { TGKey } from "@systems/tg-key"; import { format } from "@format"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types"; // ─── W.Rank formatting ──────────────────────────────────────────────────────── export function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { const tgGoal = Config.get({ section: "wrank", key: "goal" }); return format.wrank.row(wRankEntry, tgGoal, context); } // ─── 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 if (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?: TGKey } ): 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, }; } // ─── Shared embed helpers ───────────────────────────────────────────────────── export function formatNationField( nation: Nation, yesEntries: VoteEntry[], noVoters: VoteEntry[], showNoInline: boolean, historyKey?: TGKey ): 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 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"); } export function resolveColor(state: PollState): number { if (state.confirmed === "yes") return 0x57f287; // green if (state.confirmed === "no") return 0xed4245; // red if ((state as any).called) return 0xe8a317; // orange if (state.locked) return 0x888888; // grey return 0xe8a317; // orange (open) } export 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" ? " ❌" : (state as any).called ? " 🔔" : ""; return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; } export 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 as any).called) return Config.get({ section: "poll", key: "calledMessage" }) ?? "🔔 TG was called."; 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: PollState): { 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 as Nation) ?? 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 }; } // ─── buildEmbed factory ─────────────────────────────────────────────────────── export function createBuildEmbed({ inline, maxInlinePlayers = 5 }: { inline: boolean; maxInlinePlayers?: number; }) { return function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder { const { yesByNation, noVoters, allMessages } = buildYesByNation(state); const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" }); const historyKey = options?.historyKey ?? TGKey.current({ slot: state.slot }); const capellaEmoji = Emoji.get("capella"); const procyonEmoji = Emoji.get("procyon"); // Auto-stack to vertical if too many players const maxPlayers = Math.max( yesByNation[Nation.Capella].length, yesByNation[Nation.Procyon].length ); const useInline = inline && maxPlayers <= maxInlinePlayers; const embed = new EmbedBuilder() .setTitle(resolveTitle(state, yesByNation)) .setColor(resolveColor(state)) .setTimestamp(); const imageUrl = (state as any).called ? Config.get({ section: "poll", key: "calledGameImageUrl" }) : state.confirmed === "no" ? Config.get({ section: "poll", key: "cancelledImageUrl" }) : null; if (imageUrl) embed.setImage(imageUrl); if (useInline) { // Side-by-side — no spacer needed embed.addFields( { 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, }, ); } else { // Vertical — spacer between nations embed.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, }, ); } 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; }; } // ─── BaseLayout factory ─────────────────────────────────────────────────────── export const BaseLayout = { methods() { return { formatRow, buildContext }; }, };