feat: BaseLayout shared functions, WRank delta placeholder fix, Leaves system
This commit is contained in:
parent
a0876f0af8
commit
d2377ff404
7 changed files with 748 additions and 525 deletions
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
216
src/ui/poll/base-layout.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
212
src/ui/poll/layouts.bak/default.ts
Normal file
212
src/ui/poll/layouts.bak/default.ts
Normal 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,
|
||||||
|
};
|
||||||
197
src/ui/poll/layouts.bak/side-by-side.ts
Normal file
197
src/ui/poll/layouts.bak/side-by-side.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -1,176 +1,21 @@
|
||||||
/**
|
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 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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 capellaEmoji = Emoji.get("capella");
|
||||||
const procyonEmoji = Emoji.get("procyon");
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
@ -181,33 +26,28 @@
|
||||||
.addFields(
|
.addFields(
|
||||||
{
|
{
|
||||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
||||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline),
|
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
||||||
inline: false,
|
inline: false,
|
||||||
},
|
},
|
||||||
{ name: "\u200b", value: "\u200b", inline: false },
|
{ name: "\u200b", value: "\u200b", inline: false },
|
||||||
{
|
{
|
||||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
||||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline),
|
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
||||||
inline: false,
|
inline: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
const msgSection = formatMessages(allMessages);
|
const msgSection = formatMessages(allMessages);
|
||||||
if (msgSection) {
|
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||||
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layout export ────────────────────────────────────────────────────────────
|
export const defaultLayout: PollLayout = {
|
||||||
|
...BaseLayout.methods(),
|
||||||
export const defaultLayout: PollLayout = {
|
|
||||||
name: "default",
|
name: "default",
|
||||||
description: "Standard vertical layout with nation-separated fields",
|
description: "Standard vertical layout with nation-separated fields",
|
||||||
buildEmbed,
|
buildEmbed,
|
||||||
formatRow,
|
};
|
||||||
buildContext,
|
|
||||||
};
|
|
||||||
|
|
@ -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) ────────────────────────────────────────
|
|
||||||
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 capellaEmoji = Emoji.get("capella");
|
||||||
const procyonEmoji = Emoji.get("procyon");
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
|
const maxPlayers = Math.max(
|
||||||
|
yesByNation[Nation.Capella].length,
|
||||||
// Title
|
yesByNation[Nation.Procyon].length
|
||||||
const counts = !state.locked && state.confirmed === null
|
);
|
||||||
? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}`
|
const useInline = maxPlayers <= 5;
|
||||||
: "";
|
|
||||||
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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`)
|
.setTitle(resolveTitle(state, yesByNation))
|
||||||
.setColor(color)
|
.setColor(resolveColor(state))
|
||||||
.addFields(
|
.addFields(
|
||||||
// ← inline: true makes them side by side
|
|
||||||
{
|
{
|
||||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
||||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
||||||
inline: true,
|
inline: useInline,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
||||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
||||||
inline: true,
|
inline: useInline,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
const msgSection = formatMessages(allMessages);
|
const msgSection = formatMessages(allMessages);
|
||||||
if (msgSection) {
|
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||||
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 });
|
|
||||||
|
|
||||||
|
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layout export ────────────────────────────────────────────────────────────
|
export const sideBySideLayout: PollLayout = {
|
||||||
|
...BaseLayout.methods(),
|
||||||
export const sideBySideLayout: PollLayout = {
|
|
||||||
name: "side-by-side",
|
name: "side-by-side",
|
||||||
description: "Nations displayed as inline fields side by side",
|
description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)",
|
||||||
buildEmbed,
|
buildEmbed,
|
||||||
formatRow,
|
};
|
||||||
buildContext,
|
|
||||||
};
|
|
||||||
Loading…
Add table
Reference in a new issue