feat: UI layout system, Config namespace, Bootstrap phases design, Logger/Benchmark
This commit is contained in:
parent
3c4aed93df
commit
2cde01e633
20 changed files with 781 additions and 227 deletions
|
|
@ -20,6 +20,7 @@
|
|||
import { REST, Routes } from "discord.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
// Load .env
|
||||
const envPath = path.join(__dirname, "../.env");
|
||||
|
|
@ -31,8 +32,7 @@
|
|||
}
|
||||
|
||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||
const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
|
||||
.split(",").map((id) => id.trim()).filter(Boolean);
|
||||
const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
|
||||
|
||||
if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
|
||||
console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { cfg, setCfg, resetCfg } from "../systems/config";
|
|||
import { hasOfficerRole } from "../systems/users";
|
||||
import { replyAndDelete } from "../utils";
|
||||
import { Nation } from "@types";
|
||||
import { handleSetLayout } from "@subcommands/tg-config/set-layout";
|
||||
|
||||
export function buildTgConfigCommand(): SlashCommandBuilder {
|
||||
const cmd = new SlashCommandBuilder()
|
||||
|
|
@ -101,6 +102,21 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
|
|||
.addStringOption(nationOpt))
|
||||
);
|
||||
|
||||
// ── poll group ───────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("poll")
|
||||
.setDescription("Configure Poll Settings")
|
||||
.addSubcommand((s) => s
|
||||
.setName("set-layout")
|
||||
.setDescription("Change the poll display layout")
|
||||
.addStringOption((o) => o
|
||||
.setName("layout")
|
||||
.setDescription("Layout name")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
))
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
|
|
@ -208,4 +224,8 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
|
|||
if (sub === "set-no-display") { setCfg("showNoInNationField" as any, interaction.options.getString("mode", true) === "inline"); return void replyAndDelete(interaction, "✅ No voter display updated."); }
|
||||
if (sub === "set-nation-source"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source updated."); }
|
||||
}
|
||||
|
||||
if (group === "poll") {
|
||||
if (sub === "set-layout") return handleSetLayout(interaction);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { UserRegistry } from "@registry/user-registry";
|
|||
import { Paths } from "@helpers/paths";
|
||||
import { Nation } from "@types";
|
||||
import { NATION_UNICODE } from "@systems/nations";
|
||||
import { autocompleteLayout } from "@subcommands/tg-config/set-layout";
|
||||
import fs from "fs";
|
||||
|
||||
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
||||
|
|
@ -127,6 +128,7 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
|
|||
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
|
||||
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
|
||||
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
|
||||
if (optionName === "layout") return await autocompleteLayout(interaction);
|
||||
|
||||
await interaction.respond([]);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ import { Ephemeral } from "@registry/ephemeral-registry";
|
|||
import { Nation, CLASSES } from "@types";
|
||||
import { InteractionLock } from "@helpers/interaction-lock";
|
||||
import { Benchmark } from "@systems/benchmark";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
|
||||
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
||||
const LOCK_AT = Config.get("lockAt");
|
||||
|
||||
const clickCounts = new Map<string, { yes: number; no: number }>();
|
||||
const _processingVotes = new Set<string>();
|
||||
|
|
@ -244,7 +245,7 @@ export async function showActiveCharSwitching(interaction: ButtonInteraction): P
|
|||
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
|
||||
bench.mark("getEffectiveCharacter");
|
||||
if (char) {
|
||||
const starEmoji = process.env.ACTIVE_CHAR_EMOJI || "⭐";
|
||||
const starEmoji = Config.get("activeCharEmoji");
|
||||
const borrowNote = borrowedFrom ? ` 🔗` : "";
|
||||
const buttons = buildCharSelectButtons(user.userKey, {
|
||||
customIdPrefix: `companion_switch:${user.userKey}`,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { postPoll } from "../../systems/poll";
|
|||
import { resetClickCounts } from "../../handlers/buttons";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { TGSlot } from "../../types";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
export async function handleStart(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
|
|
@ -19,9 +20,9 @@ export async function handleStart(interaction: ChatInputCommandInteraction): Pro
|
|||
}
|
||||
if (!slot) return void replyAndDelete(interaction, "❌ No active TG slots configured.");
|
||||
|
||||
const channelId = cfg("pollChannelId");
|
||||
const channelId = Config.get("pollChannelId");
|
||||
console.log("pollChannelId:", channelId);
|
||||
console.log("POLL_CHANNEL_ID env:", process.env.POLL_CHANNEL_ID);
|
||||
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found.");
|
||||
|
||||
|
|
|
|||
37
src/subcommands/tg-config/set-layout.ts
Normal file
37
src/subcommands/tg-config/set-layout.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
import { PollUI } from "@ui/poll";
|
||||
import { replyAndDelete } from "@utils";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
|
||||
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, Config.get("officerRoles"))) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can change the poll layout.", true);
|
||||
}
|
||||
|
||||
const name = interaction.options.getString("layout", true);
|
||||
|
||||
if (!PollUI.setLayout(name)) {
|
||||
const available = PollUI.layouts()
|
||||
.map((l) => `\`${l.name}\` — ${l.description}`)
|
||||
.join("\n");
|
||||
return void replyAndDelete(interaction,
|
||||
`❌ Layout \`${name}\` not found. Available layouts:\n${available}`, true
|
||||
);
|
||||
}
|
||||
|
||||
Config.set("pollLayout", name);
|
||||
|
||||
return void replyAndDelete(interaction,
|
||||
`✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true
|
||||
);
|
||||
}
|
||||
|
||||
export async function autocompleteLayout(interaction: any): Promise<void> {
|
||||
const focused = interaction.options.getFocused().toLowerCase();
|
||||
const choices = PollUI.layouts()
|
||||
.filter((l) => l.name.includes(focused))
|
||||
.map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name }));
|
||||
await interaction.respond(choices);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
*/
|
||||
|
||||
import { Logger } from "@systems/logger";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
const log = Logger.for("benchmark");
|
||||
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Only profile when LOG_LEVEL=debug
|
||||
const _enabled = () => process.env.LOG_LEVEL?.toUpperCase() === "DEBUG";
|
||||
const _enabled = () => Config.get("logLevel").toUpperCase() === "DEBUG";
|
||||
|
||||
export const Benchmark = {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,64 +1,201 @@
|
|||
import path from "path";
|
||||
import { BotConfig, Nation } from "../types";
|
||||
import { Nation, TGSlot } from "@types";
|
||||
import { Store } from "@systems/store";
|
||||
import { Paths } from "@helpers/paths";
|
||||
import { Paths } from "@paths";
|
||||
|
||||
// ─── Sub-interfaces ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ChannelConfig {
|
||||
pollChannelId: string;
|
||||
resultsChannelId: string;
|
||||
scoreChannelId: string;
|
||||
}
|
||||
|
||||
interface RoleConfig {
|
||||
officerRoles: string[];
|
||||
configRoles: string[];
|
||||
tagRoles: string[];
|
||||
}
|
||||
|
||||
interface PollConfig {
|
||||
pollLayout: string;
|
||||
pollEphemeralEnabled: boolean;
|
||||
commandEphemeralEnabled: boolean;
|
||||
ephemeralDeleteMs: number;
|
||||
lockAt: number;
|
||||
lockMessage: string;
|
||||
confirmYesMessage: string;
|
||||
confirmNoMessage: string;
|
||||
charDisplayFormat: string;
|
||||
showClassInMessages: boolean;
|
||||
showLevelInMessages: boolean;
|
||||
showNoInNationField: boolean;
|
||||
showNationTotalsInHeader: boolean;
|
||||
autoVoteOnConflictSwitch: boolean;
|
||||
reclaimNotifyBorrower: boolean;
|
||||
conflictReclaimBehavior: string;
|
||||
slots: TGSlot[];
|
||||
scoreWindowHours: number;
|
||||
tgDurationMinutes: number;
|
||||
}
|
||||
|
||||
interface WRankConfig {
|
||||
wRankGoal: number;
|
||||
wRankPostOnReset: boolean;
|
||||
wRankYellowColor: string;
|
||||
wRankGrayColor: string;
|
||||
deltaUpColor: string;
|
||||
deltaDownColor: string;
|
||||
}
|
||||
|
||||
interface BringerConfig {
|
||||
stormBringerColor: string;
|
||||
luminousBringerColor: string;
|
||||
}
|
||||
|
||||
interface ImpersonateConfig {
|
||||
impersonateResetOnPoll: boolean;
|
||||
impersonateIndicator: boolean;
|
||||
}
|
||||
|
||||
interface EmojiConfig {
|
||||
activeCharEmoji: string;
|
||||
emojiDonorGuilds: string[];
|
||||
}
|
||||
|
||||
interface LoggingConfig {
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
interface NationConfig {
|
||||
nationSource: Nation;
|
||||
}
|
||||
|
||||
interface BorrowConfig {
|
||||
borrowRequestExpiryMs: number;
|
||||
}
|
||||
|
||||
// ─── BotConfig — merged, all fields optional ──────────────────────────────────
|
||||
|
||||
export type BotConfig = Partial<
|
||||
ChannelConfig &
|
||||
RoleConfig &
|
||||
PollConfig &
|
||||
WRankConfig &
|
||||
BringerConfig &
|
||||
ImpersonateConfig &
|
||||
EmojiConfig &
|
||||
LoggingConfig &
|
||||
NationConfig &
|
||||
BorrowConfig
|
||||
>;
|
||||
|
||||
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Function instead of const so env vars are read lazily at call time
|
||||
function getDefaults(): Required<BotConfig> {
|
||||
return {
|
||||
// Channels
|
||||
pollChannelId: "",
|
||||
resultsChannelId: "",
|
||||
scoreChannelId: "",
|
||||
|
||||
// Roles
|
||||
officerRoles: ["Ice King"],
|
||||
configRoles: ["Ice King"],
|
||||
tagRoles: ["Ice King", "Ice", "Rebellion"],
|
||||
|
||||
// Poll
|
||||
pollLayout: "default",
|
||||
pollEphemeralEnabled: false,
|
||||
commandEphemeralEnabled: true,
|
||||
ephemeralDeleteMs: 0,
|
||||
lockAt: 10,
|
||||
lockMessage: "🔒 This poll has been locked.",
|
||||
confirmYesMessage: "⚔️ TG is confirmed for tonight!",
|
||||
confirmNoMessage: "❌ TG is cancelled for tonight.",
|
||||
pollChannelId: process.env.POLL_CHANNEL_ID ?? "",
|
||||
resultsChannelId: process.env.RESULTS_CHANNEL_ID ?? "",
|
||||
scoreChannelId: process.env.SCORE_CHANNEL_ID ?? "",
|
||||
slots: [
|
||||
{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true },
|
||||
],
|
||||
charDisplayFormat: "{wrank} {class} {level} {name}",
|
||||
showClassInMessages: false,
|
||||
showLevelInMessages: false,
|
||||
showNoInNationField: false,
|
||||
showNationTotalsInHeader: false,
|
||||
autoVoteOnConflictSwitch: true,
|
||||
reclaimNotifyBorrower: true,
|
||||
conflictReclaimBehavior: "revert",
|
||||
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
|
||||
scoreWindowHours: 2,
|
||||
tgDurationMinutes: 35,
|
||||
nationSource: Nation.Procyon,
|
||||
wRankPostOnReset: false,
|
||||
|
||||
// W.Rank
|
||||
wRankGoal: 7,
|
||||
wRankPostOnReset: false,
|
||||
wRankYellowColor: "#BA7517",
|
||||
wRankGrayColor: "#888888",
|
||||
deltaUpColor: "#A32D2D",
|
||||
deltaDownColor: "#185FA5",
|
||||
|
||||
// Bringer
|
||||
stormBringerColor: "#185FA5",
|
||||
luminousBringerColor: "#8B4CB8",
|
||||
showClassInMessages: false,
|
||||
showLevelInMessages: false,
|
||||
charDisplayFormat: "{wrank} {class} {level} {name}",
|
||||
showNationTotalsInHeader: false,
|
||||
showNoInNationField: false,
|
||||
borrowRequestExpiryMs: 0, // 0 = never expire
|
||||
conflictReclaimBehavior: "revert",
|
||||
|
||||
// Impersonate
|
||||
impersonateResetOnPoll: false,
|
||||
impersonateIndicator: true,
|
||||
|
||||
// Emoji
|
||||
activeCharEmoji: "⭐",
|
||||
emojiDonorGuilds: [],
|
||||
|
||||
// Logging
|
||||
logLevel: "info",
|
||||
|
||||
// Nation
|
||||
nationSource: Nation.Procyon,
|
||||
|
||||
// Borrow
|
||||
borrowRequestExpiryMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _cfg: BotConfig = {};
|
||||
|
||||
export function loadConfig(): void {
|
||||
// ─── Config namespace ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Config = {
|
||||
load(): void {
|
||||
_cfg = Store.readOrDefault(Paths.data("config.json"), {});
|
||||
}
|
||||
export function saveConfig(): void {
|
||||
},
|
||||
|
||||
save(): void {
|
||||
Store.write(Paths.data("config.json"), _cfg);
|
||||
}
|
||||
},
|
||||
|
||||
export function cfg<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {
|
||||
reload(): void {
|
||||
Config.load();
|
||||
},
|
||||
|
||||
get<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {
|
||||
return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required<BotConfig>[K];
|
||||
}
|
||||
},
|
||||
|
||||
export function setCfg<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void {
|
||||
set<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void {
|
||||
_cfg[key] = value;
|
||||
saveConfig();
|
||||
}
|
||||
Config.save();
|
||||
},
|
||||
|
||||
export function resetCfg<K extends keyof BotConfig>(key: K): void {
|
||||
reset<K extends keyof BotConfig>(key: K): void {
|
||||
delete _cfg[key];
|
||||
saveConfig();
|
||||
}
|
||||
Config.save();
|
||||
},
|
||||
|
||||
all(): Required<BotConfig> {
|
||||
return { ...getDefaults(), ..._cfg };
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Legacy aliases — remove after full migration ────────────────────────────
|
||||
export const cfg = <K extends keyof BotConfig>(key: K) => Config.get(key);
|
||||
export const setCfg = <K extends keyof BotConfig>(key: K, value: BotConfig[K]) => Config.set(key, value);
|
||||
export const resetCfg = <K extends keyof BotConfig>(key: K) => Config.reset(key);
|
||||
export const loadConfig = () => Config.load();
|
||||
export const saveConfig = () => Config.save();
|
||||
|
|
@ -16,13 +16,14 @@ import { Emoji } from "@systems/emojis";
|
|||
import { format } from "@systems/format";
|
||||
import { Character } from "@types";
|
||||
import { buildCharSelectButtons } from "@systems/charSelect";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
|
||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
||||
const SWITCH_STYLE = ButtonStyle.Secondary;
|
||||
const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
|
||||
const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false";
|
||||
const AUTO_VOTE_ON_SWITCH = Config.get("autoVoteOnConflictSwitch");
|
||||
const RECLAIM_NOTIFY_BORROWER = Config.get("reclaimNotifyBorrower");
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
const pendingConflicts = new Map<string, {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { UserRegistry } from "@registry/user-registry";
|
||||
import { UsermapEntry } from "@types";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false";
|
||||
const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false";
|
||||
const IMPERSONATE_RESET_ON_POLL = Config.get("impersonateResetOnPoll");
|
||||
const IMPERSONATE_INDICATOR = Config.get("impersonateIndicator");
|
||||
|
||||
// realDiscordId → userKey being impersonated
|
||||
const impersonations = new Map<string, string>();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
* log.debug("State:", state);
|
||||
*/
|
||||
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
// ─── Log levels ───────────────────────────────────────────────────────────────
|
||||
|
||||
export enum LogLevel {
|
||||
|
|
@ -68,7 +70,7 @@ function getIcon(context: string): string {
|
|||
// ─── Global config ────────────────────────────────────────────────────────────
|
||||
|
||||
let _globalLevel: LogLevel = (() => {
|
||||
const env = process.env.LOG_LEVEL?.toUpperCase();
|
||||
const env = Config.get("logLevel").toUpperCase();
|
||||
switch (env) {
|
||||
case "DEBUG": return LogLevel.Debug;
|
||||
case "WARN": return LogLevel.Warn;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow";
|
|||
import { clearAllImpersonations } from "@systems/impersonate";
|
||||
import { Bringer } from "@systems/bringer";
|
||||
import { Attendance } from "@systems/attendance";
|
||||
import { PollUI } from "@ui/poll";
|
||||
|
||||
|
||||
// ─── Poll state ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -72,147 +73,7 @@ export function lockPoll(slot: number): void {
|
|||
Attendance.snapshot(slot, state.lockedYesKeys);
|
||||
}
|
||||
|
||||
|
||||
// ─── Character display ────────────────────────────────────────────────────────
|
||||
|
||||
function formatCharRow(entry: VoteEntry, showNationEmoji = false, nationHasRank = false): string {
|
||||
const cfgFormat = cfg("charDisplayFormat");
|
||||
const nation = entry.characterNation;
|
||||
const wRankEntry = entry.characterName && entry.characterNation
|
||||
? WRank.entry(entry.characterName, entry.characterNation)
|
||||
: null;
|
||||
|
||||
let wrank = "";
|
||||
if (wRankEntry) {
|
||||
const wRankGoal = cfg("wRankGoal");
|
||||
wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true });
|
||||
} else if (nationHasRank) {
|
||||
wrank = format.wrank.noRank();
|
||||
}
|
||||
|
||||
const classStr = entry.characterClass
|
||||
? (Emoji.class(entry.characterClass) || entry.characterClass)
|
||||
: "";
|
||||
|
||||
const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any)
|
||||
? `${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 title — independent of W.Rank so override always shows
|
||||
if (nation && entry.userKey) {
|
||||
const bringer = Bringer.get({ nation });
|
||||
|
||||
if (bringer && bringer === entry.characterName) {
|
||||
row += ` · ${format.bringer(nation)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.borrowedFrom) {
|
||||
row += ` ${Emoji.get("borrowed") || "🔗"}`;
|
||||
}
|
||||
|
||||
if (showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// ─── Embed building ───────────────────────────────────────────────────────────
|
||||
export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder {
|
||||
const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] };
|
||||
const noVoters: VoteEntry[] = [];
|
||||
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
|
||||
const showNoInline = (cfg as any)("showNoInNationField") ?? false;
|
||||
|
||||
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 formatNationField = (nation: Nation): string => {
|
||||
const yesEntries = yesByNation[nation];
|
||||
const hasRank = yesEntries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null);
|
||||
const noEntries = showNoInline
|
||||
? noVoters.filter((e) => e.characterNation === nation)
|
||||
: [];
|
||||
const lines = [
|
||||
...yesEntries.map((e) => formatCharRow(e, false, hasRank)),
|
||||
...noEntries.map((e) => `❌ ${formatCharRow(e, false, hasRank)}`),
|
||||
];
|
||||
return lines.length > 0 ? lines.join("\n") : "—";
|
||||
};
|
||||
|
||||
const formatMessages = (): 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");
|
||||
};
|
||||
|
||||
const locked = state.locked;
|
||||
const confirmed = state.confirmed;
|
||||
|
||||
const color =
|
||||
confirmed === "yes" ? 0x57f287 :
|
||||
confirmed === "no" ? 0xed4245 :
|
||||
locked ? 0x888888 :
|
||||
0xe8a317;
|
||||
|
||||
// Title with nation + no counts (hidden when confirmed or locked)
|
||||
const counts = !locked && confirmed === null
|
||||
? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}`
|
||||
: "";
|
||||
const statusSuffix =
|
||||
locked ? " 🔒" :
|
||||
confirmed === "yes" ? " ✅" :
|
||||
confirmed === "no" ? " ❌" : "";
|
||||
|
||||
const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`, value: formatNationField(Nation.Capella), inline: false },
|
||||
{ name: "\u200b", value: "\u200b", inline: false },
|
||||
{ name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`, value: formatNationField(Nation.Procyon), inline: false },
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const msgSection = formatMessages();
|
||||
if (msgSection) {
|
||||
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
||||
}
|
||||
|
||||
let footer: string;
|
||||
if (confirmed === "yes") footer = cfg("confirmYesMessage");
|
||||
else if (confirmed === "no") footer = cfg("confirmNoMessage");
|
||||
else if (locked) footer = overrideLockMsg ?? cfg("lockMessage");
|
||||
else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
||||
embed.setFooter({ text: footer });
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function buildButtons(
|
||||
disabled: boolean,
|
||||
|
|
@ -248,7 +109,7 @@ export async function updatePollMessage(
|
|||
console.log(`[updatePollMessage] components rows=${buttons.length}`);
|
||||
try {
|
||||
const msg = await channel.messages.fetch(state.messageId);
|
||||
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buttons });
|
||||
await msg.edit({ embeds: [PollUI.buildEmbed(state, { overrideLockMsg })], components: buttons });
|
||||
} catch (err) {
|
||||
console.error("Failed to update poll message:", err);
|
||||
}
|
||||
|
|
@ -267,7 +128,7 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void
|
|||
locked: false, confirmed: null,
|
||||
};
|
||||
polls.set(slot.tgHour, state);
|
||||
const msg = await channel.send({ embeds: [buildEmbed(state)], components: buildButtons(false) });
|
||||
const msg = await channel.send({ embeds: [PollUI.buildEmbed(state)], components: buildButtons(false) });
|
||||
state.messageId = msg.id;
|
||||
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
|
||||
|
||||
|
|
|
|||
33
src/types.ts
33
src/types.ts
|
|
@ -307,39 +307,6 @@ export interface BringerState {
|
|||
procyonOverride?: string;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BotConfig {
|
||||
officerRoles?: string[];
|
||||
configRoles?: string[];
|
||||
tagRoles?: string[];
|
||||
lockMessage?: string;
|
||||
confirmYesMessage?: string;
|
||||
confirmNoMessage?: string;
|
||||
pollChannelId?: string;
|
||||
resultsChannelId?: string;
|
||||
scoreChannelId?: string;
|
||||
slots?: TGSlot[];
|
||||
scoreWindowHours?: number;
|
||||
tgDurationMinutes?: number;
|
||||
nationSource?: Nation;
|
||||
wRankPostOnReset?: boolean;
|
||||
wRankGoal?: number; // default 7
|
||||
wRankYellowColor?: string; // hex
|
||||
wRankGrayColor?: string; // hex
|
||||
deltaUpColor?: string; // hex
|
||||
deltaDownColor?: string; // hex
|
||||
stormBringerColor?: string; // hex
|
||||
luminousBringerColor?: string; // hex
|
||||
showClassInMessages?: boolean;
|
||||
showLevelInMessages?: boolean;
|
||||
charDisplayFormat?: string; // "{wrank} {class} {name}"
|
||||
showNationTotalsInHeader?: boolean;
|
||||
showNoInNationField?: boolean;
|
||||
borrowRequestExpiryMs?: number; // 0 = never expire (default)
|
||||
conflictReclaimBehavior?: "revert" | "remove"
|
||||
}
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MessageEntry {
|
||||
|
|
|
|||
8
src/ui/index.ts
Normal file
8
src/ui/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { PollUI } from "./poll";
|
||||
|
||||
export const UI = {
|
||||
Poll: PollUI,
|
||||
};
|
||||
|
||||
export { PollUI };
|
||||
export type { PollLayout, PollRowContext, PollEmbedOptions } from "./types";
|
||||
101
src/ui/poll/index.ts
Normal file
101
src/ui/poll/index.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { EmbedBuilder } from "discord.js";
|
||||
import { PollState, VoteEntry, Nation } from "@types";
|
||||
import { Config } from "@systems/config";
|
||||
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// ─── Layout registry ──────────────────────────────────────────────────────────
|
||||
|
||||
const _layouts = new Map<string, PollLayout>();
|
||||
let _activeLayout: PollLayout;
|
||||
|
||||
export function registerLayout(layout: PollLayout): void {
|
||||
_layouts.set(layout.name, layout);
|
||||
if (!_activeLayout) _activeLayout = layout; // first registered = default
|
||||
}
|
||||
|
||||
function isPollLayout(obj: any): obj is PollLayout {
|
||||
return obj?.name &&
|
||||
obj?.description &&
|
||||
typeof obj?.buildEmbed === "function" &&
|
||||
typeof obj?.formatRow === "function" &&
|
||||
typeof obj?.buildContext === "function";
|
||||
}
|
||||
|
||||
export function discoverLayouts(): void {
|
||||
const layoutsDir = path.join(__dirname, "layouts");
|
||||
if (!fs.existsSync(layoutsDir)) return;
|
||||
|
||||
const files = fs.readdirSync(layoutsDir)
|
||||
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
||||
.sort(); // consistent order — default.ts loads before side-by-side.ts
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const mod = require(path.join(layoutsDir, file));
|
||||
for (const exported of Object.values(mod)) {
|
||||
if (isPollLayout(exported)) {
|
||||
registerLayout(exported);
|
||||
console.log(`[PollUI] Registered layout: ${(exported as PollLayout).name}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[PollUI] Failed to load layout ${file}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLayout() {
|
||||
const savedLayout = Config.get("pollLayout");
|
||||
|
||||
if (savedLayout && _layouts.has(savedLayout)) {
|
||||
_activeLayout = _layouts.get(savedLayout)!;
|
||||
console.log(`[PollUI] Restored layout: ${savedLayout}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discover at module load time
|
||||
Config.load();
|
||||
discoverLayouts();
|
||||
restoreLayout();
|
||||
|
||||
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
function activeLayout(): PollLayout {
|
||||
return _activeLayout;
|
||||
}
|
||||
|
||||
export const PollUI = {
|
||||
buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
||||
return activeLayout().buildEmbed(state, options);
|
||||
},
|
||||
|
||||
formatRow(entry: VoteEntry, context: PollRowContext): string {
|
||||
return activeLayout().formatRow(entry, context);
|
||||
},
|
||||
|
||||
buildContext(
|
||||
entries: VoteEntry[],
|
||||
nation: Nation,
|
||||
options?: { showNationEmoji?: boolean }
|
||||
): PollRowContext {
|
||||
return activeLayout().buildContext(entries, nation, options);
|
||||
},
|
||||
|
||||
setLayout(name: string): boolean {
|
||||
const layout = _layouts.get(name);
|
||||
if (!layout) return false;
|
||||
_activeLayout = layout;
|
||||
return true;
|
||||
},
|
||||
|
||||
layouts(): { name: string; description: string }[] {
|
||||
return [..._layouts.values()].map((l) => ({
|
||||
name: l.name,
|
||||
description: l.description,
|
||||
}));
|
||||
},
|
||||
|
||||
register: registerLayout,
|
||||
};
|
||||
204
src/ui/poll/layouts/default.ts
Normal file
204
src/ui/poll/layouts/default.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* 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, WRankEntry } from "@types";
|
||||
import { cfg } 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";
|
||||
|
||||
// ─── Row formatting ───────────────────────────────────────────────────────────
|
||||
|
||||
function formatWRank(
|
||||
wRankEntry: WRankEntry | null,
|
||||
context: PollRowContext
|
||||
): string {
|
||||
if (wRankEntry) {
|
||||
const goal = cfg("wRankGoal");
|
||||
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 = cfg("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.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 }
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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 cfg("confirmYesMessage");
|
||||
if (state.confirmed === "no") return cfg("confirmNoMessage");
|
||||
if (state.locked) return overrideLockMsg ?? cfg("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 = (cfg as any)("showNoInNationField") ?? false;
|
||||
|
||||
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,
|
||||
};
|
||||
178
src/ui/poll/layouts/side-by-side.ts
Normal file
178
src/ui/poll/layouts/side-by-side.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* 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, WRankEntry } from "@types";
|
||||
import { cfg } 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";
|
||||
|
||||
// ─── Row formatting (same as default) ────────────────────────────────────────
|
||||
|
||||
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
|
||||
if (wRankEntry) {
|
||||
return format.wrank.full(wRankEntry, { goal: cfg("wRankGoal"), brackets: true });
|
||||
}
|
||||
if (!context.nationHasRank) return "";
|
||||
if (context.nationHasDelta) return format.wrank.noRank();
|
||||
return "—";
|
||||
}
|
||||
|
||||
function formatRow(entry: VoteEntry, context: PollRowContext): string {
|
||||
const cfgFormat = cfg("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.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 }
|
||||
): 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 };
|
||||
}
|
||||
|
||||
// ─── 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 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 = (cfg as any)("showNoInNationField") ?? false;
|
||||
|
||||
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");
|
||||
|
||||
// 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),
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline),
|
||||
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 = cfg("confirmYesMessage");
|
||||
else if (state.confirmed === "no") footer = cfg("confirmNoMessage");
|
||||
else if (state.locked) footer = options?.overrideLockMsg ?? cfg("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,
|
||||
};
|
||||
27
src/ui/types.ts
Normal file
27
src/ui/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* UI types — shared interfaces for all UI modules.
|
||||
*/
|
||||
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import { PollState, VoteEntry, Nation } from "@types";
|
||||
|
||||
// ─── Poll ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PollRowContext {
|
||||
nationHasRank: boolean;
|
||||
nationHasDelta: boolean;
|
||||
showNationEmoji?: boolean;
|
||||
}
|
||||
|
||||
export interface PollEmbedOptions {
|
||||
overrideLockMsg?: string;
|
||||
showScoreButton?: boolean;
|
||||
}
|
||||
|
||||
export interface PollLayout {
|
||||
name: string;
|
||||
description: string;
|
||||
buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder;
|
||||
formatRow(entry: VoteEntry, context: PollRowContext): string;
|
||||
buildContext(entries: VoteEntry[], nation: Nation, options?: { showNationEmoji?: boolean }): PollRowContext;
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||
import { Config } from "@systems/config";
|
||||
|
||||
// Poll vote confirmation messages (Yes/No button responses)
|
||||
const POLL_EPHEMERAL_ENABLED = process.env.POLL_EPHEMERAL_ENABLED !== "false";
|
||||
const POLL_EPHEMERAL_ENABLED = Config.get("pollEphemeralEnabled");
|
||||
// Command output messages (score, rank, status etc.) — always on by default
|
||||
const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false";
|
||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||
const COMMAND_EPHEMERAL_ENABLED = Config.get("commandEphemeralEnabled");
|
||||
const EPHEMERAL_DELETE_MS = Config.get("ephemeralDeleteMs");
|
||||
|
||||
// For poll button responses
|
||||
export async function pollReplyAndDelete(
|
||||
|
|
|
|||
|
|
@ -33,8 +33,12 @@
|
|||
"@paths": ["src/helpers/paths"],
|
||||
"@characters": ["src/systems/characters"],
|
||||
"@systems/scheduler": ["src/systems/scheduler/index"],
|
||||
"@ui": ["src/ui/index"],
|
||||
"@ui/*": ["src/ui/*"],
|
||||
"@ui/poll": ["src/ui/poll/index"],
|
||||
"@ui/types": ["src/ui/types"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "scripts/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue