feat: BaseLayout shared functions, WRank delta placeholder fix, Leaves system

This commit is contained in:
Nuno Duque Nunes 2026-06-11 23:53:06 +01:00
parent a0876f0af8
commit d2377ff404
7 changed files with 748 additions and 525 deletions

View file

@ -80,12 +80,20 @@ function wrankRank(entry: WRankEntry, goal: number): string {
* Format the delta indicator for a wrank entry. * Format the delta indicator for a wrank entry.
* Output: <:wrank_up:><:wrank_up_2:> or 2, empty string if no change * 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 brackets = options?.brackets ?? true;
const prev = entry.previousRank; const prev = entry.previousRank;
const delta = prev !== undefined ? entry.currentRank - prev : 0; 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; let inner: string;
if (delta < 0) { 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. * 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 { function wrankNoRank({ delta = false }: { delta?: boolean } = {}): string {
const norank = Emoji.get("wrank_no_dash") || "—"; const dash = Emoji.get("wrank_no_dash") || "—";
const dash = Emoji.get("wrank_no_rank_delta") || "—"; if (!delta) return dash; // just "—" when no delta context
const square = Emoji.get("wrank_no_dash") || "■"; const zero = Emoji.get("wrank_no_rank_delta") || "0";
return `${norank} (${square}${dash})`; return `${dash} (${dash}${zero})`; // "— ( — 0 )" when others have delta
} }
// ─── Bringer formatters ──────────────────────────────────────────────────────── // ─── Bringer formatters ────────────────────────────────────────────────────────

View file

@ -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<void>;
type LockCallback = (slot: TGSlot) => Promise<void>;
type CloseCallback = (slot: TGSlot) => Promise<void>;
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.`);
},
};

216
src/ui/poll/base-layout.ts Normal file
View file

@ -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<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}`;
}
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<Nation, VoteEntry[]>;
noVoters: VoteEntry[];
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[];
} {
const yesByNation: Record<Nation, VoteEntry[]> = {
[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,
};
},
};

View file

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

View file

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

View file

@ -1,213 +1,53 @@
/** import { EmbedBuilder } from "discord.js";
* Default poll layout vertical, nation-separated fields. import { PollState, Nation, VoteEntry } from "@types";
* This is the standard layout and always the fallback. 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"; function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
import { PollState, VoteEntry, Nation } from "@types"; const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
import { WRankEntry } from "@systems/wrank"; const showNoInline = false; // default layout stacks no-voters
import { Config } from "@systems/config"; const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
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 ─────────────────────────────────────────────────────────── const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
function formatWRank( const embed = new EmbedBuilder()
wRankEntry: WRankEntry | null, .setTitle(resolveTitle(state, yesByNation))
context: PollRowContext .setColor(resolveColor(state))
): string { .addFields(
if (wRankEntry) { {
const goal = Config.get({ section: "wrank", key: "goal" }); name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
return format.wrank.full(wRankEntry, { goal, brackets: true }); value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
} inline: false,
if (!context.nationHasRank) return ""; },
if (context.nationHasDelta) return format.wrank.noRank(); { name: "\u200b", value: "\u200b", inline: false },
return "—"; {
} name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: false,
},
)
.setTimestamp();
function formatRow(entry: VoteEntry, context: PollRowContext): string { const msgSection = formatMessages(allMessages);
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
? WRank.entry(entry.characterName, entry.characterNation) return embed;
: null; }
const wrank = formatWRank(wRankEntry, context); export const defaultLayout: PollLayout = {
const classStr = entry.characterClass ...BaseLayout.methods(),
? (Emoji.class(entry.characterClass) || entry.characterClass) name: "default",
: ""; description: "Standard vertical layout with nation-separated fields",
const levelStr = entry.characterLevel ? `${entry.characterLevel}` : ""; buildEmbed,
};
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,
};

View file

@ -1,194 +1,58 @@
/** import { EmbedBuilder } from "discord.js";
* Side-by-side poll layout Capella and Procyon displayed as inline fields. import { PollState, Nation } from "@types";
* Nations appear next to each other rather than stacked vertically. 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"; function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
import { PollState, VoteEntry, Nation } from "@types"; const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
import { WRankEntry } from "@systems/wrank"; const showNoInline = false;
import { Config } from "@systems/config"; const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
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) ──────────────────────────────────────── const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { const maxPlayers = Math.max(
if (wRankEntry) { yesByNation[Nation.Capella].length,
return format.wrank.full(wRankEntry, { goal: Config.get({ section: "wrank", key: "goal" }), brackets: true }); yesByNation[Nation.Procyon].length
} );
if (!context.nationHasRank) return ""; const useInline = maxPlayers <= 5;
if (context.nationHasDelta) return format.wrank.noRank();
return "—";
}
function formatRow(entry: VoteEntry, context: PollRowContext): string { const embed = new EmbedBuilder()
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); .setTitle(resolveTitle(state, yesByNation))
const nation = entry.characterNation; .setColor(resolveColor(state))
const wRankEntry = entry.characterName && entry.characterNation .addFields(
? WRank.entry(entry.characterName, entry.characterNation) {
: null; 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 wrank = formatWRank(wRankEntry, context); const msgSection = formatMessages(allMessages);
const classStr = entry.characterClass ? (Emoji.class(entry.characterClass) || entry.characterClass) : ""; if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
const levelStr = entry.characterLevel ? `${entry.characterLevel}` : "";
let row = cfgFormat embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
.replace("{wrank}", wrank) return embed;
.replace("{class}", classStr) }
.replace("{level}", levelStr)
.replace("{name}", entry.characterName ?? entry.displayName ?? "")
.replace(/\s+/g, " ")
.trim();
if (nation && entry.userKey) { export const sideBySideLayout: PollLayout = {
const bringer = Bringer.get({ nation }); ...BaseLayout.methods(),
if (bringer && bringer === entry.characterName) { name: "side-by-side",
row += ` · ${format.bringer(nation)}`; description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)",
} buildEmbed,
} };
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, 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 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,
};