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 { REST, Routes } from "discord.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
// Load .env
|
// Load .env
|
||||||
const envPath = path.join(__dirname, "../.env");
|
const envPath = path.join(__dirname, "../.env");
|
||||||
|
|
@ -31,8 +32,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
|
const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
|
||||||
.split(",").map((id) => id.trim()).filter(Boolean);
|
|
||||||
|
|
||||||
if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
|
if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
|
||||||
console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env");
|
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 { hasOfficerRole } from "../systems/users";
|
||||||
import { replyAndDelete } from "../utils";
|
import { replyAndDelete } from "../utils";
|
||||||
import { Nation } from "@types";
|
import { Nation } from "@types";
|
||||||
|
import { handleSetLayout } from "@subcommands/tg-config/set-layout";
|
||||||
|
|
||||||
export function buildTgConfigCommand(): SlashCommandBuilder {
|
export function buildTgConfigCommand(): SlashCommandBuilder {
|
||||||
const cmd = new SlashCommandBuilder()
|
const cmd = new SlashCommandBuilder()
|
||||||
|
|
@ -101,6 +102,21 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
|
||||||
.addStringOption(nationOpt))
|
.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;
|
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-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 (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 { Paths } from "@helpers/paths";
|
||||||
import { Nation } from "@types";
|
import { Nation } from "@types";
|
||||||
import { NATION_UNICODE } from "@systems/nations";
|
import { NATION_UNICODE } from "@systems/nations";
|
||||||
|
import { autocompleteLayout } from "@subcommands/tg-config/set-layout";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -127,6 +128,7 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
|
||||||
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
|
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
|
||||||
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
|
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
|
||||||
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
|
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
|
||||||
|
if (optionName === "layout") return await autocompleteLayout(interaction);
|
||||||
|
|
||||||
await interaction.respond([]);
|
await interaction.respond([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ import { Ephemeral } from "@registry/ephemeral-registry";
|
||||||
import { Nation, CLASSES } from "@types";
|
import { Nation, CLASSES } from "@types";
|
||||||
import { InteractionLock } from "@helpers/interaction-lock";
|
import { InteractionLock } from "@helpers/interaction-lock";
|
||||||
import { Benchmark } from "@systems/benchmark";
|
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 clickCounts = new Map<string, { yes: number; no: number }>();
|
||||||
const _processingVotes = new Set<string>();
|
const _processingVotes = new Set<string>();
|
||||||
|
|
@ -244,7 +245,7 @@ export async function showActiveCharSwitching(interaction: ButtonInteraction): P
|
||||||
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
|
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
|
||||||
bench.mark("getEffectiveCharacter");
|
bench.mark("getEffectiveCharacter");
|
||||||
if (char) {
|
if (char) {
|
||||||
const starEmoji = process.env.ACTIVE_CHAR_EMOJI || "⭐";
|
const starEmoji = Config.get("activeCharEmoji");
|
||||||
const borrowNote = borrowedFrom ? ` 🔗` : "";
|
const borrowNote = borrowedFrom ? ` 🔗` : "";
|
||||||
const buttons = buildCharSelectButtons(user.userKey, {
|
const buttons = buildCharSelectButtons(user.userKey, {
|
||||||
customIdPrefix: `companion_switch:${user.userKey}`,
|
customIdPrefix: `companion_switch:${user.userKey}`,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { postPoll } from "../../systems/poll";
|
||||||
import { resetClickCounts } from "../../handlers/buttons";
|
import { resetClickCounts } from "../../handlers/buttons";
|
||||||
import { replyAndDelete } from "../../utils";
|
import { replyAndDelete } from "../../utils";
|
||||||
import { TGSlot } from "../../types";
|
import { TGSlot } from "../../types";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
export async function handleStart(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleStart(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const slotArg = interaction.options.getString("slot");
|
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.");
|
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("pollChannelId:", channelId);
|
||||||
console.log("POLL_CHANNEL_ID env:", process.env.POLL_CHANNEL_ID);
|
|
||||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||||
if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found.");
|
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 { Logger } from "@systems/logger";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
const log = Logger.for("benchmark");
|
const log = Logger.for("benchmark");
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@
|
||||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Only profile when LOG_LEVEL=debug
|
// Only profile when LOG_LEVEL=debug
|
||||||
const _enabled = () => process.env.LOG_LEVEL?.toUpperCase() === "DEBUG";
|
const _enabled = () => Config.get("logLevel").toUpperCase() === "DEBUG";
|
||||||
|
|
||||||
export const Benchmark = {
|
export const Benchmark = {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,201 @@
|
||||||
import path from "path";
|
import { Nation, TGSlot } from "@types";
|
||||||
import { BotConfig, Nation } from "../types";
|
|
||||||
import { Store } from "@systems/store";
|
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> {
|
function getDefaults(): Required<BotConfig> {
|
||||||
return {
|
return {
|
||||||
|
// Channels
|
||||||
|
pollChannelId: "",
|
||||||
|
resultsChannelId: "",
|
||||||
|
scoreChannelId: "",
|
||||||
|
|
||||||
|
// Roles
|
||||||
officerRoles: ["Ice King"],
|
officerRoles: ["Ice King"],
|
||||||
configRoles: ["Ice King"],
|
configRoles: ["Ice King"],
|
||||||
tagRoles: ["Ice King", "Ice", "Rebellion"],
|
tagRoles: ["Ice King", "Ice", "Rebellion"],
|
||||||
|
|
||||||
|
// Poll
|
||||||
|
pollLayout: "default",
|
||||||
|
pollEphemeralEnabled: false,
|
||||||
|
commandEphemeralEnabled: true,
|
||||||
|
ephemeralDeleteMs: 0,
|
||||||
|
lockAt: 10,
|
||||||
lockMessage: "🔒 This poll has been locked.",
|
lockMessage: "🔒 This poll has been locked.",
|
||||||
confirmYesMessage: "⚔️ TG is confirmed for tonight!",
|
confirmYesMessage: "⚔️ TG is confirmed for tonight!",
|
||||||
confirmNoMessage: "❌ TG is cancelled for tonight.",
|
confirmNoMessage: "❌ TG is cancelled for tonight.",
|
||||||
pollChannelId: process.env.POLL_CHANNEL_ID ?? "",
|
charDisplayFormat: "{wrank} {class} {level} {name}",
|
||||||
resultsChannelId: process.env.RESULTS_CHANNEL_ID ?? "",
|
showClassInMessages: false,
|
||||||
scoreChannelId: process.env.SCORE_CHANNEL_ID ?? "",
|
showLevelInMessages: false,
|
||||||
slots: [
|
showNoInNationField: false,
|
||||||
{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true },
|
showNationTotalsInHeader: false,
|
||||||
],
|
autoVoteOnConflictSwitch: true,
|
||||||
|
reclaimNotifyBorrower: true,
|
||||||
|
conflictReclaimBehavior: "revert",
|
||||||
|
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
|
||||||
scoreWindowHours: 2,
|
scoreWindowHours: 2,
|
||||||
tgDurationMinutes: 35,
|
tgDurationMinutes: 35,
|
||||||
nationSource: Nation.Procyon,
|
|
||||||
wRankPostOnReset: false,
|
// W.Rank
|
||||||
wRankGoal: 7,
|
wRankGoal: 7,
|
||||||
|
wRankPostOnReset: false,
|
||||||
wRankYellowColor: "#BA7517",
|
wRankYellowColor: "#BA7517",
|
||||||
wRankGrayColor: "#888888",
|
wRankGrayColor: "#888888",
|
||||||
deltaUpColor: "#A32D2D",
|
deltaUpColor: "#A32D2D",
|
||||||
deltaDownColor: "#185FA5",
|
deltaDownColor: "#185FA5",
|
||||||
|
|
||||||
|
// Bringer
|
||||||
stormBringerColor: "#185FA5",
|
stormBringerColor: "#185FA5",
|
||||||
luminousBringerColor: "#8B4CB8",
|
luminousBringerColor: "#8B4CB8",
|
||||||
showClassInMessages: false,
|
|
||||||
showLevelInMessages: false,
|
// Impersonate
|
||||||
charDisplayFormat: "{wrank} {class} {level} {name}",
|
impersonateResetOnPoll: false,
|
||||||
showNationTotalsInHeader: false,
|
impersonateIndicator: true,
|
||||||
showNoInNationField: false,
|
|
||||||
borrowRequestExpiryMs: 0, // 0 = never expire
|
// Emoji
|
||||||
conflictReclaimBehavior: "revert",
|
activeCharEmoji: "⭐",
|
||||||
|
emojiDonorGuilds: [],
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
logLevel: "info",
|
||||||
|
|
||||||
|
// Nation
|
||||||
|
nationSource: Nation.Procyon,
|
||||||
|
|
||||||
|
// Borrow
|
||||||
|
borrowRequestExpiryMs: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _cfg: BotConfig = {};
|
let _cfg: BotConfig = {};
|
||||||
|
|
||||||
export function loadConfig(): void {
|
// ─── Config namespace ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Config = {
|
||||||
|
load(): void {
|
||||||
_cfg = Store.readOrDefault(Paths.data("config.json"), {});
|
_cfg = Store.readOrDefault(Paths.data("config.json"), {});
|
||||||
}
|
},
|
||||||
export function saveConfig(): void {
|
|
||||||
|
save(): void {
|
||||||
Store.write(Paths.data("config.json"), _cfg);
|
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];
|
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;
|
_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];
|
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 { format } from "@systems/format";
|
||||||
import { Character } from "@types";
|
import { Character } from "@types";
|
||||||
import { buildCharSelectButtons } from "@systems/charSelect";
|
import { buildCharSelectButtons } from "@systems/charSelect";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
|
|
||||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
||||||
const SWITCH_STYLE = ButtonStyle.Secondary;
|
const SWITCH_STYLE = ButtonStyle.Secondary;
|
||||||
const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
|
const AUTO_VOTE_ON_SWITCH = Config.get("autoVoteOnConflictSwitch");
|
||||||
const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false";
|
const RECLAIM_NOTIFY_BORROWER = Config.get("reclaimNotifyBorrower");
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
const pendingConflicts = new Map<string, {
|
const pendingConflicts = new Map<string, {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { UserRegistry } from "@registry/user-registry";
|
import { UserRegistry } from "@registry/user-registry";
|
||||||
import { UsermapEntry } from "@types";
|
import { UsermapEntry } from "@types";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false";
|
const IMPERSONATE_RESET_ON_POLL = Config.get("impersonateResetOnPoll");
|
||||||
const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false";
|
const IMPERSONATE_INDICATOR = Config.get("impersonateIndicator");
|
||||||
|
|
||||||
// realDiscordId → userKey being impersonated
|
// realDiscordId → userKey being impersonated
|
||||||
const impersonations = new Map<string, string>();
|
const impersonations = new Map<string, string>();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
* log.debug("State:", state);
|
* log.debug("State:", state);
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
// ─── Log levels ───────────────────────────────────────────────────────────────
|
// ─── Log levels ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export enum LogLevel {
|
export enum LogLevel {
|
||||||
|
|
@ -68,7 +70,7 @@ function getIcon(context: string): string {
|
||||||
// ─── Global config ────────────────────────────────────────────────────────────
|
// ─── Global config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _globalLevel: LogLevel = (() => {
|
let _globalLevel: LogLevel = (() => {
|
||||||
const env = process.env.LOG_LEVEL?.toUpperCase();
|
const env = Config.get("logLevel").toUpperCase();
|
||||||
switch (env) {
|
switch (env) {
|
||||||
case "DEBUG": return LogLevel.Debug;
|
case "DEBUG": return LogLevel.Debug;
|
||||||
case "WARN": return LogLevel.Warn;
|
case "WARN": return LogLevel.Warn;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow";
|
||||||
import { clearAllImpersonations } from "@systems/impersonate";
|
import { clearAllImpersonations } from "@systems/impersonate";
|
||||||
import { Bringer } from "@systems/bringer";
|
import { Bringer } from "@systems/bringer";
|
||||||
import { Attendance } from "@systems/attendance";
|
import { Attendance } from "@systems/attendance";
|
||||||
|
import { PollUI } from "@ui/poll";
|
||||||
|
|
||||||
|
|
||||||
// ─── Poll state ───────────────────────────────────────────────────────────────
|
// ─── Poll state ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -72,147 +73,7 @@ export function lockPoll(slot: number): void {
|
||||||
Attendance.snapshot(slot, state.lockedYesKeys);
|
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 ───────────────────────────────────────────────────────────
|
// ─── 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(
|
export function buildButtons(
|
||||||
disabled: boolean,
|
disabled: boolean,
|
||||||
|
|
@ -248,7 +109,7 @@ export async function updatePollMessage(
|
||||||
console.log(`[updatePollMessage] components rows=${buttons.length}`);
|
console.log(`[updatePollMessage] components rows=${buttons.length}`);
|
||||||
try {
|
try {
|
||||||
const msg = await channel.messages.fetch(state.messageId);
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to update poll message:", 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,
|
locked: false, confirmed: null,
|
||||||
};
|
};
|
||||||
polls.set(slot.tgHour, state);
|
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;
|
state.messageId = msg.id;
|
||||||
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
|
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;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MessageEntry {
|
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 { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
|
||||||
// Poll vote confirmation messages (Yes/No button responses)
|
// 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
|
// Command output messages (score, rank, status etc.) — always on by default
|
||||||
const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false";
|
const COMMAND_EPHEMERAL_ENABLED = Config.get("commandEphemeralEnabled");
|
||||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
const EPHEMERAL_DELETE_MS = Config.get("ephemeralDeleteMs");
|
||||||
|
|
||||||
// For poll button responses
|
// For poll button responses
|
||||||
export async function pollReplyAndDelete(
|
export async function pollReplyAndDelete(
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,12 @@
|
||||||
"@paths": ["src/helpers/paths"],
|
"@paths": ["src/helpers/paths"],
|
||||||
"@characters": ["src/systems/characters"],
|
"@characters": ["src/systems/characters"],
|
||||||
"@systems/scheduler": ["src/systems/scheduler/index"],
|
"@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"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue