tg-bot-ts/src/ui/poll/layouts/default.ts
2026-06-11 05:17:29 +01:00

213 lines
No EOL
7.8 KiB
TypeScript

/**
* 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) {
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)}`;
}
}
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<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 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, VoteEntry[]> = {
[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,
};