feat: Leaderboard & Result systems with aligned columns, call/confirm-no commands, persistent message slots
- TextAlign: column alignment for embeds using real gg sans font metrics - EmbedHelpers: per-player grid/column layouts immune to 1024-char field limit - Layout: domain-aware formatting wrapper (wrank, bringer, cockroach, tgCount) - PersistentMessage: multi-slot support for independently-updatable embeds - Leaderboard: weekly rankings + highlights embed (most kills/deaths, next Bringer) - Result: per-TG breakdown with wRankAtSubmission snapshot for historical accuracy - /tg call, /tg poll confirm-no, /tg-admin score-inject, result/leaderboard post commands - Fix: CharacterRegistry wasn't hydrating ownerKey, breaking K/D bot-wide - Fix: Leaderboard.buildEntries used current week instead of passed-in week param - /tg-admin test-align: permanent calibration tool for embed text alignment Includes data/emojis/anima-mastery.json for new combat stat icons.
This commit is contained in:
parent
b22602f431
commit
9e8877483d
63 changed files with 3673 additions and 607 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,6 +21,7 @@ data/sessionPreferences.json
|
||||||
data/tg-history/
|
data/tg-history/
|
||||||
data/updates/.message-ids.json
|
data/updates/.message-ids.json
|
||||||
data/.message-ids/
|
data/.message-ids/
|
||||||
|
data/snapshots/
|
||||||
# Emoji data
|
# Emoji data
|
||||||
emoji-uploads/
|
emoji-uploads/
|
||||||
|
|
||||||
|
|
|
||||||
8
data/emojis/anima-mastery.json
Normal file
8
data/emojis/anima-mastery.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"anima_anima_atk": "<:anima_anima_atk:1517697805181784126>",
|
||||||
|
"anima_anima_def": "<:anima_anima_def:1517700444116484246>",
|
||||||
|
"anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>",
|
||||||
|
"anima_atk": "<:anima_atk:1517702182710018179>",
|
||||||
|
"anima_def": "<:anima_def:1517700561468657785>",
|
||||||
|
"anima_massheal": "<:anima_massheal:1517702186434433146>"
|
||||||
|
}
|
||||||
|
|
@ -10,5 +10,7 @@
|
||||||
"wrank_down": "<:wrank_down:1511906547104616643>",
|
"wrank_down": "<:wrank_down:1511906547104616643>",
|
||||||
"wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>",
|
"wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>",
|
||||||
"wrank_up": "<:wrank_up:1512114414474756132>",
|
"wrank_up": "<:wrank_up:1512114414474756132>",
|
||||||
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>"
|
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>",
|
||||||
|
"slash": "<:slash:1516648012422844416>",
|
||||||
|
"tg_flag": "<:tg_flag:1516946073392910336>"
|
||||||
}
|
}
|
||||||
|
|
@ -98,5 +98,6 @@
|
||||||
"wrank_down_96": "<:wrank_down_96:1513360449276477509>",
|
"wrank_down_96": "<:wrank_down_96:1513360449276477509>",
|
||||||
"wrank_down_97": "<:wrank_down_97:1513360453152280709>",
|
"wrank_down_97": "<:wrank_down_97:1513360453152280709>",
|
||||||
"wrank_down_98": "<:wrank_down_98:1513360459758174328>",
|
"wrank_down_98": "<:wrank_down_98:1513360459758174328>",
|
||||||
"wrank_down_99": "<:wrank_down_99:1513360463558082590>"
|
"wrank_down_99": "<:wrank_down_99:1513360463558082590>",
|
||||||
|
"wrank_down_0": "<:wrank_down_0:1516648019297046709>"
|
||||||
}
|
}
|
||||||
|
|
@ -98,5 +98,6 @@
|
||||||
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
|
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
|
||||||
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
|
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
|
||||||
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
|
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
|
||||||
"wrank_up_99": "<:wrank_up_99:1513360776071745576>"
|
"wrank_up_99": "<:wrank_up_99:1513360776071745576>",
|
||||||
|
"wrank_up_0": "<:wrank_up_0:1516648023084503113>"
|
||||||
}
|
}
|
||||||
|
|
@ -18,5 +18,6 @@
|
||||||
"wrank_6": "<:wrank_6:1512124952738795581>",
|
"wrank_6": "<:wrank_6:1512124952738795581>",
|
||||||
"wrank_7": "<:wrank_7:1512124956622979143>",
|
"wrank_7": "<:wrank_7:1512124956622979143>",
|
||||||
"wrank_8": "<:wrank_8:1512124961450496020>",
|
"wrank_8": "<:wrank_8:1512124961450496020>",
|
||||||
"wrank_9": "<:wrank_9:1512124965363650631>"
|
"wrank_9": "<:wrank_9:1512124965363650631>",
|
||||||
|
"wrank_0": "<:wrank_0:1516648016008712243>"
|
||||||
}
|
}
|
||||||
|
|
@ -262,5 +262,16 @@
|
||||||
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
|
"wrank_up_96": "<:wrank_up_96:1513360762666746008>",
|
||||||
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
|
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
|
||||||
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
|
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
|
||||||
"wrank_up_99": "<:wrank_up_99:1513360776071745576>"
|
"wrank_up_99": "<:wrank_up_99:1513360776071745576>",
|
||||||
|
"slash": "<:slash:1516648012422844416>",
|
||||||
|
"wrank_0": "<:wrank_0:1516648016008712243>",
|
||||||
|
"wrank_down_0": "<:wrank_down_0:1516648019297046709>",
|
||||||
|
"wrank_up_0": "<:wrank_up_0:1516648023084503113>",
|
||||||
|
"tg_flag": "<:tg_flag:1516946073392910336>",
|
||||||
|
"anima_anima_atk": "<:anima_anima_atk:1517697805181784126>",
|
||||||
|
"anima_anima_def": "<:anima_anima_def:1517700444116484246>",
|
||||||
|
"anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>",
|
||||||
|
"anima_atk": "<:anima_atk:1517702182710018179>",
|
||||||
|
"anima_def": "<:anima_def:1517700561468657785>",
|
||||||
|
"anima_massheal": "<:anima_massheal:1517702186434433146>"
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
"start": "ts-node -r tsconfig-paths/register src/index.ts",
|
"start": "ts-node -r tsconfig-paths/register src/index.ts",
|
||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
"register": "ts-node -r tsconfig-paths/register src/index.ts --register",
|
"register": "ts-node -r tsconfig-paths/register src/index.ts --register",
|
||||||
"aliases": "ts-node scripts/generate-aliases.ts"
|
"aliases": "ts-node scripts/generate-aliases.ts",
|
||||||
|
"upload-emojis": "ts-node -r tsconfig-paths/register scripts/upload-emojis.ts",
|
||||||
|
"split-emojis": "ts-node -r tsconfig-paths/register scripts/split-emojis.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "^14.15.3",
|
"discord.js": "^14.15.3",
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Config.load();
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
|
const DONOR_GUILD_IDS = Config.get({ section: "emoji", key: "donorGuilds" });
|
||||||
|
|
||||||
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 must be set in .env and emoji.donorGuilds must be configured in config.json");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +57,7 @@
|
||||||
"wrank_up": (f) => `wrank_up_${f}`,
|
"wrank_up": (f) => `wrank_up_${f}`,
|
||||||
"wrank_down": (f) => `wrank_down_${f}`,
|
"wrank_down": (f) => `wrank_down_${f}`,
|
||||||
"wrank_x": (f) => `wrank_x_${f}`,
|
"wrank_x": (f) => `wrank_x_${f}`,
|
||||||
|
"anima-mastery_stats": (f) => `anima_${f}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveEmojiName(dirName: string, filename: string): string {
|
function resolveEmojiName(dirName: string, filename: string): string {
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,27 @@ import { handleCharActive } from "@subcommands/char/active";
|
||||||
import { Nation } from "@types";
|
import { Nation } from "@types";
|
||||||
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
|
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
|
||||||
|
|
||||||
|
import { CallCommands } from "@subcommands/poll/call";
|
||||||
|
import { ConfirmNoCommands } from "@subcommands/poll/confirm-no";
|
||||||
|
|
||||||
export function buildTgCommand(): SlashCommandBuilder {
|
export function buildTgCommand(): SlashCommandBuilder {
|
||||||
const cmd = new SlashCommandBuilder()
|
const cmd = new SlashCommandBuilder()
|
||||||
.setName("tg")
|
.setName("tg")
|
||||||
.setDescription("TG planning and tracking");
|
.setDescription("TG planning and tracking");
|
||||||
|
|
||||||
|
// ── root ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd.addSubcommand((s) => s
|
||||||
|
.setName("call")
|
||||||
|
.setDescription("Call TG early (ended before 35 min)")
|
||||||
|
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
|
cmd.addSubcommand((s) => s
|
||||||
|
.setName("confirm-no")
|
||||||
|
.setDescription("Confirm TG is cancelled")
|
||||||
|
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
|
|
||||||
// ── poll group ─────────────────────────────────────────────────────────────
|
// ── poll group ─────────────────────────────────────────────────────────────
|
||||||
cmd.addSubcommandGroup((g) => g
|
cmd.addSubcommandGroup((g) => g
|
||||||
.setName("poll")
|
.setName("poll")
|
||||||
|
|
@ -327,6 +343,7 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
|
||||||
if (sub === "seed") return handleSeed(interaction);
|
if (sub === "seed") return handleSeed(interaction);
|
||||||
if (sub === "mark-left") return handleMarkLeft(interaction);
|
if (sub === "mark-left") return handleMarkLeft(interaction);
|
||||||
if (sub === "unmark-left") return handleUnmarkLeft(interaction);
|
if (sub === "unmark-left") return handleUnmarkLeft(interaction);
|
||||||
|
if (sub === "confirm-no") return ConfirmNoCommands.confirmNo(interaction);
|
||||||
}
|
}
|
||||||
if (group === "score") {
|
if (group === "score") {
|
||||||
if (sub === "set") return handleScoreSet(interaction);
|
if (sub === "set") return handleScoreSet(interaction);
|
||||||
|
|
@ -361,4 +378,6 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
|
||||||
if (!group && sub === "switch") return handleSwitch(interaction);
|
if (!group && sub === "switch") return handleSwitch(interaction);
|
||||||
if (!group && sub === "history") return handleHistory(interaction);
|
if (!group && sub === "history") return handleHistory(interaction);
|
||||||
if (!group && sub === "impersonate") return handleImpersonate(interaction);
|
if (!group && sub === "impersonate") return handleImpersonate(interaction);
|
||||||
|
if (!group && sub === "call") return CallCommands.call(interaction);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import {
|
||||||
} from "@subcommands/admin/userMap";
|
} from "@subcommands/admin/userMap";
|
||||||
|
|
||||||
import { UpdatesCommands } from "@subcommands/admin/updates";
|
import { UpdatesCommands } from "@subcommands/admin/updates";
|
||||||
|
import { ScoreInjectCommands } from "@subcommands/admin/score-inject";
|
||||||
|
import { ResultCommands } from "@subcommands/admin/result-post";
|
||||||
|
import { TestAlignCommands } from "@subcommands/admin/test-align";
|
||||||
|
|
||||||
export function buildTgAdminCommand(): SlashCommandBuilder {
|
export function buildTgAdminCommand(): SlashCommandBuilder {
|
||||||
const cmd = new SlashCommandBuilder()
|
const cmd = new SlashCommandBuilder()
|
||||||
|
|
@ -97,13 +100,70 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
|
||||||
.setName("list")
|
.setName("list")
|
||||||
.setDescription("List all versions and their post status")
|
.setDescription("List all versions and their post status")
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── result group ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd.addSubcommandGroup((g) => g
|
||||||
|
.setName("result")
|
||||||
|
.setDescription("Manage TG results")
|
||||||
|
.addSubcommand((s) => s
|
||||||
|
.setName("post")
|
||||||
|
.setDescription("Post or edit a TG result")
|
||||||
|
.addStringOption((o) => o.setName("history_key").setDescription("TG to post").setRequired(true).setAutocomplete(true))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── leaderboard group ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd.addSubcommandGroup((g) => g
|
||||||
|
.setName("leaderboard")
|
||||||
|
.setDescription("Manage leaderboard")
|
||||||
|
.addSubcommand((s) => s
|
||||||
|
.setName("post")
|
||||||
|
.setDescription("Post or edit the leaderboard")
|
||||||
|
.addStringOption((o) => o.setName("week_key").setDescription("Week e.g. 2026-W24 (defaults current)").setAutocomplete(true))
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => s
|
||||||
|
.setName("post-highlights")
|
||||||
|
.setDescription("Post or edit the leaderboard highlights")
|
||||||
|
.addStringOption((o) => o.setName("week_key").setDescription("Week e.g. 2026-W24 (defaults current)").setAutocomplete(true))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cmd.addSubcommand((s) => s
|
||||||
|
.setName("score-inject")
|
||||||
|
.setDescription("Inject a score for any player (officer only)")
|
||||||
|
.addStringOption((o) => o.setName("char_name").setDescription("Character").setRequired(true).setAutocomplete(true))
|
||||||
|
.addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true))
|
||||||
|
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)"))
|
||||||
|
.addIntegerOption((o) => o.setName("k").setDescription("Kills"))
|
||||||
|
.addIntegerOption((o) => o.setName("d").setDescription("Deaths"))
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd.addSubcommand((s) => s
|
||||||
|
.setName("test-align")
|
||||||
|
.setDescription("[TEMP] Test embed text alignment calibration")
|
||||||
|
.addStringOption((o) => o.setName("name_a").setDescription("First name").setRequired(true))
|
||||||
|
.addStringOption((o) => o.setName("name_b").setDescription("Second name").setRequired(true))
|
||||||
|
.addIntegerOption((o) => o.setName("fillers").setDescription("Filler count to add to name_a").setRequired(true))
|
||||||
|
.addStringOption((o) => o
|
||||||
|
.setName("filler_type")
|
||||||
|
.setDescription("Which invisible character to use")
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Hangul Filler", value: "hangul" },
|
||||||
|
{ name: "Thin Space", value: "thin" },
|
||||||
|
{ name: "Hair Space", value: "hair" },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const group = interaction.options.getSubcommandGroup(true);
|
const group = interaction.options.getSubcommandGroup(false);
|
||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
if (group === "user") {
|
if (group === "user") {
|
||||||
|
|
@ -122,4 +182,11 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
|
||||||
if (sub === "preview") return UpdatesCommands.preview(interaction);
|
if (sub === "preview") return UpdatesCommands.preview(interaction);
|
||||||
if (sub === "list") return UpdatesCommands.list(interaction);
|
if (sub === "list") return UpdatesCommands.list(interaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (group === null && sub === "score-inject") return ScoreInjectCommands.inject(interaction);
|
||||||
|
if (group === "result" && sub === "post") return ResultCommands.post(interaction);
|
||||||
|
if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction);
|
||||||
|
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction);
|
||||||
|
|
||||||
|
if (group === null && sub === "test-align") return TestAlignCommands.handle(interaction);
|
||||||
}
|
}
|
||||||
|
|
@ -4,6 +4,9 @@ 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";
|
import { handleSetLayout } from "@subcommands/tg-config/set-layout";
|
||||||
|
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
|
||||||
|
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
|
||||||
|
import { SetLayoutCommands } from "@subcommands/tg-config/set-layout";
|
||||||
|
|
||||||
const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = {
|
const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = {
|
||||||
officerRoles: "officer",
|
officerRoles: "officer",
|
||||||
|
|
@ -123,6 +126,24 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// In poll group or as separate groups:
|
||||||
|
cmd.addSubcommand((s) => s
|
||||||
|
.setName("set-result-layout")
|
||||||
|
.setDescription("Change the TG result display layout")
|
||||||
|
.addStringOption((o) => o
|
||||||
|
.setName("layout").setDescription("Layout name")
|
||||||
|
.setRequired(true).setAutocomplete(true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
cmd.addSubcommand((s) => s
|
||||||
|
.setName("set-leaderboard-layout")
|
||||||
|
.setDescription("Change the leaderboard display layout")
|
||||||
|
.addStringOption((o) => o
|
||||||
|
.setName("layout").setDescription("Layout name")
|
||||||
|
.setRequired(true).setAutocomplete(true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +158,7 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
|
||||||
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
|
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = options.getSubcommandGroup(true);
|
const group = options.getSubcommandGroup();
|
||||||
const sub = options.getSubcommand();
|
const sub = options.getSubcommand();
|
||||||
|
|
||||||
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
|
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
|
||||||
|
|
@ -242,4 +263,8 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
|
||||||
if (group === "poll") {
|
if (group === "poll") {
|
||||||
if (sub === "set-layout") return handleSetLayout(interaction);
|
if (sub === "set-layout") return handleSetLayout(interaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (group === null && sub === "set-result-layout") return SetResultLayoutCommands.handle(interaction);
|
||||||
|
if (group === null && sub === "set-leaderboard-layout") return SetLeaderboardLayoutCommands.handle(interaction);
|
||||||
|
if (group === null && sub === "set-layout") return SetLayoutCommands.handle(interaction);
|
||||||
}
|
}
|
||||||
24
src/discord/client.ts
Normal file
24
src/discord/client.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export { Interaction } from "./interaction";
|
||||||
|
export { Guild } from "./guild";
|
||||||
|
export { Channel } from "./channel";
|
||||||
|
|
||||||
|
// ─── Client registry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { Client } from "discord.js";
|
||||||
|
|
||||||
|
let _client: Client | null = null;
|
||||||
|
|
||||||
|
export function setClient(client: Client): void {
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClient(): Client {
|
||||||
|
if (!_client) throw new Error("[Discord] Client not initialized — call setClient() first");
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const DiscordClient = {
|
||||||
|
get: getClient,
|
||||||
|
set: setClient
|
||||||
|
};
|
||||||
|
|
@ -9,17 +9,20 @@
|
||||||
* Discord.Channel.fetch({ client, id })
|
* Discord.Channel.fetch({ client, id })
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Interaction } from "./interaction";
|
export { Interaction } from "./interaction";
|
||||||
export { Guild } from "./guild";
|
export { Guild } from "./guild";
|
||||||
export { Channel } from "./channel";
|
export { Channel } from "./channel";
|
||||||
|
|
||||||
// Top-level namespace for convenience
|
// Top-level namespace for convenience
|
||||||
import { Interaction } from "./interaction";
|
import { Interaction } from "./interaction";
|
||||||
import { Guild } from "./guild";
|
import { Guild } from "./guild";
|
||||||
import { Channel } from "./channel";
|
import { Channel } from "./channel";
|
||||||
|
|
||||||
export const Discord = {
|
// ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Discord = {
|
||||||
Interaction,
|
Interaction,
|
||||||
Guild,
|
Guild,
|
||||||
Channel,
|
Channel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ 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 { autocompleteLayout } from "@subcommands/tg-config/set-layout";
|
||||||
import { UpdatesCommands } from "@subcommands/admin/updates";
|
import { UpdatesCommands } from "@subcommands/admin/updates";
|
||||||
|
import { ResultCommands } from "@subcommands/admin/result-post";
|
||||||
|
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
|
||||||
|
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -129,8 +132,15 @@ 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);
|
|
||||||
if (optionName === "version") return UpdatesCommands.autocomplete(interaction);
|
if (optionName === "version") return UpdatesCommands.autocomplete(interaction);
|
||||||
|
if (optionName === "history_key") return await ResultCommands.autocompleteHistory(interaction);
|
||||||
|
if (optionName === "week_key") return await ResultCommands.autocompleteWeekKey(interaction);
|
||||||
|
|
||||||
|
if (optionName === "layout") {
|
||||||
|
if (sub === "set-result-layout") return await SetResultLayoutCommands.autocomplete(interaction);
|
||||||
|
if (sub === "set-leaderboard-layout") return await SetLeaderboardLayoutCommands.autocomplete(interaction);
|
||||||
|
return await autocompleteLayout(interaction); // poll default
|
||||||
|
}
|
||||||
|
|
||||||
await interaction.respond([]);
|
await interaction.respond([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
20
src/index.ts
20
src/index.ts
|
|
@ -8,7 +8,11 @@ import { TGSlot } from "@src/types";
|
||||||
import { persist } from "@systems/pollPersistence"
|
import { persist } from "@systems/pollPersistence"
|
||||||
import { buildTgAdminCommand } from "@commands/tgAdmin";
|
import { buildTgAdminCommand } from "@commands/tgAdmin";
|
||||||
import { Scheduler } from "@systems/scheduler";
|
import { Scheduler } from "@systems/scheduler";
|
||||||
import { Runtime } from "@systems/runtime";
|
import { Runtime, RuntimeEvents } from "@systems/runtime";
|
||||||
|
import { Leaderboard } from "@systems/leaderboard";
|
||||||
|
import { Result } from "@systems/result";
|
||||||
|
import { Attendance } from "@systems/attendance";
|
||||||
|
import { DiscordClient } from "@src/discord/client";
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||||
|
|
@ -73,6 +77,20 @@ client.once("clientReady", async () => {
|
||||||
|
|
||||||
await Runtime.start();
|
await Runtime.start();
|
||||||
|
|
||||||
|
DiscordClient.set(client);
|
||||||
|
|
||||||
|
// Register event handlers
|
||||||
|
RuntimeEvents.on("scoreSubmitted", async ({ historyKey }) => {
|
||||||
|
await Leaderboard.update();
|
||||||
|
if (Attendance.allSubmitted(historyKey)) {
|
||||||
|
RuntimeEvents.emit("allScoresSubmitted", { historyKey });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
|
||||||
|
await Result.post({ historyKey });
|
||||||
|
});
|
||||||
|
|
||||||
const restored = persist.load();
|
const restored = persist.load();
|
||||||
if (restored) {
|
if (restored) {
|
||||||
for (const [slot, state] of restored) polls.set(slot, state);
|
for (const [slot, state] of restored) polls.set(slot, state);
|
||||||
|
|
|
||||||
77
src/subcommands/admin/result-post.ts
Normal file
77
src/subcommands/admin/result-post.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { Result } from "@systems/result";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
import { Paths } from "@paths";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const historyKey = opts.string({ key: "history_key" });
|
||||||
|
|
||||||
|
if (!historyKey || !TGKey.isValid(historyKey)) {
|
||||||
|
await Discord.Interaction.editReply(interaction, "❌ Invalid or missing history key.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Result.post({ historyKey: historyKey as TGKey });
|
||||||
|
await Discord.Interaction.editReply(interaction, `✅ Result posted for \`${TGKey.toDisplay(historyKey as TGKey)}\`.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleLeaderboardPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const weekKey = opts.string({ key: "week_key" }) ?? undefined;
|
||||||
|
const { Leaderboard } = require("@systems/leaderboard");
|
||||||
|
await Leaderboard.update({ weekKey });
|
||||||
|
await Discord.Interaction.editReply(interaction, `✅ Leaderboard updated${weekKey ? ` for \`${weekKey}\`` : ""}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleLeaderboardPostHighlights(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const weekKey = opts.string({ key: "week_key" }) ?? undefined;
|
||||||
|
const { Leaderboard } = require("@systems/leaderboard");
|
||||||
|
await Leaderboard.updateHighlights({ weekKey });
|
||||||
|
await Discord.Interaction.editReply(interaction, `✅ Leaderboard highlights updated${weekKey ? ` for \`${weekKey}\`` : ""}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autocompleteWeekKey(interaction: any): Promise<void> {
|
||||||
|
const focused = interaction.options.getFocused().toLowerCase();
|
||||||
|
const { WRank } = require("@systems/wrank");
|
||||||
|
const weeks = Object.keys(WRank.allWeeks())
|
||||||
|
.filter((k: string) => k.toLowerCase().includes(focused))
|
||||||
|
.sort().reverse().slice(0, 25)
|
||||||
|
.map((k: string) => ({ name: k, value: k }));
|
||||||
|
await interaction.respond(weeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autocompleteHistoryKey(interaction: any): Promise<void> {
|
||||||
|
const focused = interaction.options.getFocused().toLowerCase();
|
||||||
|
const histDir = Paths.data("tg-history");
|
||||||
|
if (!fs.existsSync(histDir)) { await interaction.respond([]); return; }
|
||||||
|
|
||||||
|
const choices = fs.readdirSync(histDir)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
|
.map((f) => f.replace(".json", ""))
|
||||||
|
.filter((k) => TGKey.isValid(k) && TGKey.toDisplay(k as TGKey).toLowerCase().includes(focused))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((k) => ({
|
||||||
|
name: TGKey.toDisplay(k as TGKey),
|
||||||
|
value: k,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await interaction.respond(choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResultCommands = {
|
||||||
|
post: handleResultPost,
|
||||||
|
leaderboardPost: handleLeaderboardPost,
|
||||||
|
leaderboardHighlights: handleLeaderboardPostHighlights,
|
||||||
|
autocompleteHistory: autocompleteHistoryKey,
|
||||||
|
autocompleteWeekKey: autocompleteWeekKey,
|
||||||
|
};
|
||||||
56
src/subcommands/admin/score-inject.ts
Normal file
56
src/subcommands/admin/score-inject.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { CharacterRegistry } from "@registry/character-registry";
|
||||||
|
import { Score } from "@systems/score";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
import { RuntimeEvents } from "@systems/runtime";
|
||||||
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
|
||||||
|
export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||||
|
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||||
|
await Discord.Interaction.editReply(interaction, "❌ Officer only.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const charName = opts.string({ key: "char_name", required: true })!;
|
||||||
|
const pts = opts.integer({ key: "pts", required: true })!;
|
||||||
|
const slot = opts.integer({ key: "slot", required: true })!;
|
||||||
|
const dateStr = opts.string({ key: "date" }) ?? new Date().toISOString().slice(0, 10);
|
||||||
|
const k = opts.integer({ key: "k" }) ?? undefined;
|
||||||
|
const d = opts.integer({ key: "d" }) ?? undefined;
|
||||||
|
|
||||||
|
const char = CharacterRegistry.find(charName);
|
||||||
|
if (!char) {
|
||||||
|
await Discord.Interaction.editReply(interaction, `❌ Character **${charName}** not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyKey = TGKey.from({ date: dateStr, slot });
|
||||||
|
|
||||||
|
Score.submit({
|
||||||
|
character: char,
|
||||||
|
pts,
|
||||||
|
k,
|
||||||
|
d,
|
||||||
|
slot,
|
||||||
|
submittedByOfficer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character: char });
|
||||||
|
await Discord.Interaction.editReply(interaction,
|
||||||
|
`✅ Score injected for **${char.name}** — 📊 ${pts}${k !== undefined ? ` ⚔️ ${k}/${d ?? 0}` : ""} · \`${TGKey.toDisplay(historyKey)}\``
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScoreInjectCommands = {
|
||||||
|
inject: handleScoreInject,
|
||||||
|
autocompleteHistory: async (interaction: any) => {
|
||||||
|
const { autocompleteHistoryKey } = require("./result-post");
|
||||||
|
await autocompleteHistoryKey(interaction);
|
||||||
|
},
|
||||||
|
};
|
||||||
42
src/subcommands/admin/test-align.ts
Normal file
42
src/subcommands/admin/test-align.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Temporary calibration tool for TextAlign filler-character widths inside
|
||||||
|
* EMBEDS specifically (plain messages render differently than embed fields).
|
||||||
|
* Remove once TextAlign calibration is finalized.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChatInputCommandInteraction, EmbedBuilder } from "discord.js";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
|
||||||
|
const FILLER_CHARS: Record<string, string> = {
|
||||||
|
hangul: "ㅤ", // U+3164 Hangul Filler
|
||||||
|
thin: "\u2009", // Thin Space
|
||||||
|
hair: "\u200a", // Hair Space
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handleTestAlign(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const nameA = opts.string({ key: "name_a", required: true })!;
|
||||||
|
const nameB = opts.string({ key: "name_b", required: true })!;
|
||||||
|
const fillers = opts.integer({ key: "fillers", required: true })!;
|
||||||
|
const charKey = opts.string({ key: "filler_type" }) ?? "hangul";
|
||||||
|
|
||||||
|
const FILLER = FILLER_CHARS[charKey] ?? FILLER_CHARS.hangul;
|
||||||
|
const paddedA = nameA + FILLER.repeat(fillers);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("🧪 Alignment Test (addFields)")
|
||||||
|
.addFields(
|
||||||
|
{ name: "\u200b", value: `${paddedA}|`, inline: false },
|
||||||
|
{ name: "\u200b", value: `${nameB}|`, inline: false },
|
||||||
|
{ name: "\u200b", value: `\`${nameA}\` (${nameA.length} chars) + ${fillers} \`${charKey}\` fillers vs \`${nameB}\` (${nameB.length} chars)`, inline: false },
|
||||||
|
)
|
||||||
|
.setColor(0x5865f2);
|
||||||
|
|
||||||
|
await Discord.Interaction.editReply(interaction, { content: "", embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestAlignCommands = {
|
||||||
|
handle: handleTestAlign,
|
||||||
|
};
|
||||||
38
src/subcommands/poll/call.ts
Normal file
38
src/subcommands/poll/call.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { polls, updatePollMessage } from "@systems/poll";
|
||||||
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
import { replyAndDelete } from "@utils";
|
||||||
|
|
||||||
|
export async function handleCall(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
const callRoles = Config.get({ section: "roles", key: "callGame" });
|
||||||
|
|
||||||
|
if (!hasOfficerRole(member, callRoles)) {
|
||||||
|
return void replyAndDelete(interaction, "❌ You don't have permission to call TG.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const slot = opts.integer({ key: "slot", required: true })!;
|
||||||
|
const state = polls.get(slot);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return void replyAndDelete(interaction, `❌ No active poll for ${slot}:00.`, true);
|
||||||
|
}
|
||||||
|
if (!state.locked) {
|
||||||
|
return void replyAndDelete(interaction, "❌ Poll must be locked before calling TG.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.called = true;
|
||||||
|
state.calledAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const channel = interaction.channel as TextChannel;
|
||||||
|
await updatePollMessage(channel, slot);
|
||||||
|
|
||||||
|
return void replyAndDelete(interaction, `✅ TG at ${slot}:00 has been called.`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CallCommands = {
|
||||||
|
call: handleCall,
|
||||||
|
};
|
||||||
33
src/subcommands/poll/confirm-no.ts
Normal file
33
src/subcommands/poll/confirm-no.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { polls, updatePollMessage } from "@systems/poll";
|
||||||
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
import { replyAndDelete } from "@utils";
|
||||||
|
|
||||||
|
export async function handleConfirmNo(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||||
|
return void replyAndDelete(interaction, "❌ Officer only.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const slot = opts.integer({ key: "slot", required: true })!;
|
||||||
|
const state = polls.get(slot);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return void replyAndDelete(interaction, `❌ No active poll for ${slot}:00.`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.confirmed = "no";
|
||||||
|
state.locked = true;
|
||||||
|
|
||||||
|
const channel = interaction.channel as TextChannel;
|
||||||
|
await updatePollMessage(channel, slot);
|
||||||
|
|
||||||
|
return void replyAndDelete(interaction, `✅ TG at ${slot}:00 confirmed as cancelled.`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmNoCommands = {
|
||||||
|
confirmNo: handleConfirmNo,
|
||||||
|
};
|
||||||
|
|
@ -53,9 +53,9 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
|
||||||
const lines = [
|
const lines = [
|
||||||
`**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`,
|
`**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`,
|
||||||
`${scoreEmoji} **${score.pts}** pts`,
|
`${scoreEmoji} **${score.pts}** pts`,
|
||||||
score.atk !== undefined ? `ATK: ${score.atk}` : null,
|
score.stats?.atk !== undefined ? `ATK: ${score.stats.atk}` : null,
|
||||||
score.def !== undefined ? `DEF: ${score.def}` : null,
|
score.stats?.def !== undefined ? `DEF: ${score.stats.def}` : null,
|
||||||
score.heal !== undefined ? `HEAL: ${score.heal}` : null,
|
score.stats?.heal !== undefined ? `HEAL: ${score.stats.heal}` : null,
|
||||||
`*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`,
|
`*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`,
|
||||||
].filter(Boolean).join("\n");
|
].filter(Boolean).join("\n");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ export namespace score {
|
||||||
|
|
||||||
const scoreEmoji = getEmoji("score") || "📊";
|
const scoreEmoji = getEmoji("score") || "📊";
|
||||||
const kdEmoji = getEmoji("kd") || "⚔️";
|
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||||
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
|
|
||||||
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
|
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
|
||||||
const statsNote = [
|
const statsNote = [
|
||||||
atk !== undefined ? `ATK: ${atk}` : null,
|
atk !== undefined ? `ATK: ${atk}` : null,
|
||||||
|
|
@ -77,10 +76,22 @@ export namespace score {
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
const charDisplay = format.char(char);
|
const charDisplay = format.char(char);
|
||||||
|
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
|
||||||
|
|
||||||
|
const line = format.scoreSubmitLine({
|
||||||
|
slot,
|
||||||
|
char,
|
||||||
|
pts,
|
||||||
|
k,
|
||||||
|
d,
|
||||||
|
atk,
|
||||||
|
def,
|
||||||
|
heal,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
|
message: `✅ ${line}${borrowNote}`,
|
||||||
// message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,47 +1,38 @@
|
||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
import { Config } from "@systems/config";
|
import { Config } from "@systems/config";
|
||||||
import { PollUI } from "@ui/poll";
|
import { PollUI } from "@ui/poll";
|
||||||
import { replyAndDelete } from "@utils";
|
import { Discord } from "@discord";
|
||||||
import { hasOfficerRole } from "@systems/users";
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
|
||||||
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
// discord.js types CommandInteractionOptionResolver with Omit<> which hides
|
|
||||||
// methods like getString/getInteger at the type level despite them existing at runtime
|
|
||||||
// Needs to be cast as any, since Discord.js has issues with the type
|
|
||||||
const options = interaction.options as any;
|
|
||||||
|
|
||||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||||
return void replyAndDelete(interaction, "❌ Only officers can change the poll layout.", true);
|
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = options.getString("layout", true);
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const name = opts.string({ key: "layout", required: true })!;
|
||||||
|
|
||||||
if (!PollUI.setLayout(name)) {
|
if (!PollUI.setLayout(name)) {
|
||||||
const available = PollUI.layouts()
|
const available = PollUI.layouts().map((l) => `\`${l.name}\` — ${l.description}`).join("\n");
|
||||||
.map((l) => `\`${l.name}\` — ${l.description}`)
|
await Discord.Interaction.reply(interaction, { content: `❌ Layout \`${name}\` not found.\n${available}`, ephemeral: true });
|
||||||
.join("\n");
|
return;
|
||||||
return void replyAndDelete(interaction,
|
|
||||||
`❌ Layout \`${name}\` not found. Available layouts:\n${available}`, true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Config.set({ section: "poll", key: "layout", value: name });
|
Config.set({ section: "poll", key: "layout", value: name });
|
||||||
|
await Discord.Interaction.reply(interaction, { content: `✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, ephemeral: true });
|
||||||
return void replyAndDelete(interaction,
|
|
||||||
`✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function autocompleteLayout(interaction: any): Promise<void> {
|
export async function autocompleteLayout(interaction: any): Promise<void> {
|
||||||
// discord.js types CommandInteractionOptionResolver with Omit<> which hides
|
const focused = interaction.options.getFocused().toLowerCase();
|
||||||
// methods like getString/getInteger at the type level despite them existing at runtime
|
|
||||||
// Needs to be cast as any, since Discord.js has issues with the type
|
|
||||||
const options = interaction.options as any;
|
|
||||||
|
|
||||||
const focused = options.getFocused().toLowerCase();
|
|
||||||
const choices = PollUI.layouts()
|
const choices = PollUI.layouts()
|
||||||
.filter((l) => l.name.includes(focused))
|
.filter((l) => l.name.includes(focused))
|
||||||
.map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name }));
|
.map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name }));
|
||||||
await interaction.respond(choices);
|
await interaction.respond(choices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SetLayoutCommands = {
|
||||||
|
handle: handleSetLayout,
|
||||||
|
autocomplete: autocompleteLayout,
|
||||||
|
};
|
||||||
38
src/subcommands/tg-config/set-leaderboard-layout.ts
Normal file
38
src/subcommands/tg-config/set-leaderboard-layout.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { LeaderboardUI } from "@ui/leaderboard";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
|
||||||
|
export async function handleSetLeaderboardLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||||
|
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const name = opts.string({ key: "layout", required: true })!;
|
||||||
|
|
||||||
|
if (!LeaderboardUI.setLayout(name)) {
|
||||||
|
const available = LeaderboardUI.layouts().map((l) => `\`${l.name}\` — ${l.description}`).join("\n");
|
||||||
|
await Discord.Interaction.reply(interaction, { content: `❌ Layout \`${name}\` not found.\n${available}`, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.set({ section: "leaderboard", key: "layout", value: name });
|
||||||
|
await Discord.Interaction.reply(interaction, { content: `✅ Leaderboard layout set to \`${name}\`.`, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autocompleteLeaderboardLayout(interaction: any): Promise<void> {
|
||||||
|
const focused = interaction.options.getFocused().toLowerCase();
|
||||||
|
const choices = LeaderboardUI.layouts()
|
||||||
|
.filter((l) => l.name.includes(focused))
|
||||||
|
.map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name }));
|
||||||
|
await interaction.respond(choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetLeaderboardLayoutCommands = {
|
||||||
|
handle: handleSetLeaderboardLayout,
|
||||||
|
autocomplete: autocompleteLeaderboardLayout,
|
||||||
|
};
|
||||||
38
src/subcommands/tg-config/set-result-layout.ts
Normal file
38
src/subcommands/tg-config/set-result-layout.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { ResultUI } from "@ui/result";
|
||||||
|
import { Discord } from "@discord";
|
||||||
|
import { hasOfficerRole } from "@systems/users";
|
||||||
|
|
||||||
|
export async function handleSetResultLayout(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||||
|
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
|
||||||
|
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = Discord.Interaction.options(interaction);
|
||||||
|
const name = opts.string({ key: "layout", required: true })!;
|
||||||
|
|
||||||
|
if (!ResultUI.setLayout(name)) {
|
||||||
|
const available = ResultUI.layouts().map((l) => `\`${l.name}\` — ${l.description}`).join("\n");
|
||||||
|
await Discord.Interaction.reply(interaction, { content: `❌ Layout \`${name}\` not found.\n${available}`, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Config.set({ section: "result", key: "layout", value: name });
|
||||||
|
await Discord.Interaction.reply(interaction, { content: `✅ Result layout set to \`${name}\`.`, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autocompleteResultLayout(interaction: any): Promise<void> {
|
||||||
|
const focused = interaction.options.getFocused().toLowerCase();
|
||||||
|
const choices = ResultUI.layouts()
|
||||||
|
.filter((l) => l.name.includes(focused))
|
||||||
|
.map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name }));
|
||||||
|
await interaction.respond(choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetResultLayoutCommands = {
|
||||||
|
handle: handleSetResultLayout,
|
||||||
|
autocomplete: autocompleteResultLayout,
|
||||||
|
};
|
||||||
|
|
@ -13,12 +13,14 @@ interface ChannelConfig {
|
||||||
results: string;
|
results: string;
|
||||||
score: string;
|
score: string;
|
||||||
updates: string;
|
updates: string;
|
||||||
|
leaderboard: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleConfig {
|
interface RoleConfig {
|
||||||
officer: string[];
|
officer: string[];
|
||||||
config: string[];
|
config: string[];
|
||||||
tag: string[];
|
tag: string[];
|
||||||
|
callGame: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PollConfig {
|
interface PollConfig {
|
||||||
|
|
@ -38,9 +40,20 @@ interface PollConfig {
|
||||||
autoVoteOnConflict: boolean;
|
autoVoteOnConflict: boolean;
|
||||||
reclaimNotifyBorrower: boolean;
|
reclaimNotifyBorrower: boolean;
|
||||||
conflictReclaimBehavior: string;
|
conflictReclaimBehavior: string;
|
||||||
|
calledGameImageUrl?: string;
|
||||||
|
cancelledImageUrl?: string;
|
||||||
|
calledMessage?: string;
|
||||||
slots: TGSlot[];
|
slots: TGSlot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResultConfig {
|
||||||
|
layout: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardConfig {
|
||||||
|
layout: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface WRankConfig {
|
interface WRankConfig {
|
||||||
goal: number;
|
goal: number;
|
||||||
postOnReset: boolean;
|
postOnReset: boolean;
|
||||||
|
|
@ -84,9 +97,11 @@ export interface SectionMap {
|
||||||
channels: ChannelConfig;
|
channels: ChannelConfig;
|
||||||
roles: RoleConfig;
|
roles: RoleConfig;
|
||||||
poll: PollConfig;
|
poll: PollConfig;
|
||||||
|
result: ResultConfig;
|
||||||
|
leaderboard: LeaderboardConfig;
|
||||||
wrank: WRankConfig;
|
wrank: WRankConfig;
|
||||||
bringer: BringerConfig;
|
bringer: BringerConfig;
|
||||||
impersonate:ImpersonateConfig;
|
impersonate: ImpersonateConfig;
|
||||||
emoji: EmojiConfig;
|
emoji: EmojiConfig;
|
||||||
nation: NationConfig;
|
nation: NationConfig;
|
||||||
borrow: BorrowConfig;
|
borrow: BorrowConfig;
|
||||||
|
|
@ -103,12 +118,14 @@ function getDefaults(): SectionMap {
|
||||||
poll: "",
|
poll: "",
|
||||||
results: "",
|
results: "",
|
||||||
score: "",
|
score: "",
|
||||||
updates: ""
|
updates: "",
|
||||||
|
leaderboard: ""
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
officer: ["Ice King"],
|
officer: ["Ice King"],
|
||||||
config: ["Ice King"],
|
config: ["Ice King"],
|
||||||
tag: ["Ice King", "Ice", "Rebellion"],
|
tag: ["Ice King", "Ice", "Rebellion"],
|
||||||
|
callGame: ["Ice King"],
|
||||||
},
|
},
|
||||||
poll: {
|
poll: {
|
||||||
layout: "default",
|
layout: "default",
|
||||||
|
|
@ -129,6 +146,12 @@ function getDefaults(): SectionMap {
|
||||||
conflictReclaimBehavior: "revert",
|
conflictReclaimBehavior: "revert",
|
||||||
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
|
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
|
||||||
},
|
},
|
||||||
|
result: {
|
||||||
|
layout: "default"
|
||||||
|
},
|
||||||
|
leaderboard: {
|
||||||
|
layout: "default"
|
||||||
|
},
|
||||||
wrank: {
|
wrank: {
|
||||||
goal: 7,
|
goal: 7,
|
||||||
postOnReset: false,
|
postOnReset: false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Character, CharacterClass, Nation } from "@src/types";
|
import { Character, CharacterClass, ClassKey, Nation, TGStats } from "@src/types";
|
||||||
import { Emoji } from "@systems/emojis";
|
import { Emoji } from "@systems/emojis";
|
||||||
import { WRankEntry } from "@systems/wrank";
|
import { WRankEntry } from "@systems/wrank";
|
||||||
|
import { LeaderboardEntry } from "./leaderboard";
|
||||||
|
|
||||||
// ─── Individual formatters ────────────────────────────────────────────────────
|
// ─── Individual formatters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -21,7 +22,8 @@ function charButton(c: Character, options?: { shared?: boolean }): string {
|
||||||
function char(c: Character, options?: CharDisplayOptions): string {
|
function char(c: Character, options?: CharDisplayOptions): string {
|
||||||
const showEmoji = options?.emoji ?? true;
|
const showEmoji = options?.emoji ?? true;
|
||||||
const showLevel = options?.level ?? true;
|
const showLevel = options?.level ?? true;
|
||||||
const classKey = c.class.key;
|
const classKey = typeof c.class === "object" ? c.class?.key : c.class;
|
||||||
|
if (!classKey) return `${c.level} ${c.name}`; // fallback if no class
|
||||||
const classStr = showEmoji ? (Emoji.class(classKey) || classKey) : classKey;
|
const classStr = showEmoji ? (Emoji.class(classKey) || classKey) : classKey;
|
||||||
const levelStr = showLevel ? `${c.level} ` : "";
|
const levelStr = showLevel ? `${c.level} ` : "";
|
||||||
return `${classStr} ${levelStr}${c.name}`.trim();
|
return `${classStr} ${levelStr}${c.name}`.trim();
|
||||||
|
|
@ -142,6 +144,102 @@ function date(date: Date | string, fmt: string = "dd/MM/YYYY"): string {
|
||||||
.replace("mm", String(d.getMinutes()).padStart(2, "0"));
|
.replace("mm", String(d.getMinutes()).padStart(2, "0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Number formatters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function abbrev(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
||||||
|
return `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorNumber(n: number, prefix: "wrank_up" | "wrank_down"): string {
|
||||||
|
if (n <= 200) {
|
||||||
|
const emoji = Emoji.get(`${prefix}_${n}`);
|
||||||
|
if (emoji) return emoji;
|
||||||
|
}
|
||||||
|
return String(n).split("").map((d) => Emoji.get(`${prefix}_${d}`) || d).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TG / Stats / K/D formatters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatTGCount(tgCount: number, tgGoal: number): string {
|
||||||
|
if (tgCount >= tgGoal) {
|
||||||
|
const emoji = Emoji.get(`wrank_${tgGoal}_gold`) || `${tgGoal}`;
|
||||||
|
return `${emoji}${emoji}`;
|
||||||
|
}
|
||||||
|
const doneEmoji = Emoji.get(`wrank_${tgCount}`) || `${tgCount}`;
|
||||||
|
const goalEmoji = Emoji.get(`wrank_${tgGoal}_gold`) || `${tgGoal}`;
|
||||||
|
return `${doneEmoji}${goalEmoji}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStats(stats?: TGStats): string {
|
||||||
|
if (!stats) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (stats.atk) parts.push(`${Emoji.get("atk") || "⚔️"} ${format.number.abbrev(stats.atk)}`);
|
||||||
|
if (stats.def) parts.push(`${Emoji.get("def") || "🛡️"} ${format.number.abbrev(stats.def)}`);
|
||||||
|
if (stats.heal) parts.push(`${Emoji.get("heal") || "💚"} ${format.number.abbrev(stats.heal)}`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKd(kills: number, deaths: number): string {
|
||||||
|
const kdEmoji = Emoji.get("kd") || "⚔️";
|
||||||
|
return `${kdEmoji} ${colorNumber(kills, "wrank_down")}/${colorNumber(deaths, "wrank_up")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single secondary stat with its emoji.
|
||||||
|
* Returns empty string if value is falsy/undefined.
|
||||||
|
*/
|
||||||
|
function statText(emojiKey: string, value: number | undefined, fallback: string = "📊"): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const emoji = Emoji.get(emojiKey) || fallback;
|
||||||
|
return `${emoji} ${format.number.abbrev(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Score submission line ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the score submission confirmation line.
|
||||||
|
* "<upload> (20:00 TG) WI 79 »Flash« <score> 2000 <kd> 18/4 <atk> X <def> X <heal> X"
|
||||||
|
*/
|
||||||
|
function scoreSubmitLine(params: {
|
||||||
|
slot: number;
|
||||||
|
char: { class: { key: ClassKey }; level: number; name: string };
|
||||||
|
pts: number;
|
||||||
|
k?: number;
|
||||||
|
d?: number;
|
||||||
|
atk?: number;
|
||||||
|
def?: number;
|
||||||
|
heal?: number;
|
||||||
|
}): string {
|
||||||
|
const uploadEmoji = Emoji.get("upload") || "⬆️";
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const classStr = Emoji.class(params.char.class.key) || params.char.class.key;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
uploadEmoji,
|
||||||
|
`(${params.slot}:00 TG)`,
|
||||||
|
classStr,
|
||||||
|
`${params.char.level}`,
|
||||||
|
params.char.name,
|
||||||
|
scoreEmoji,
|
||||||
|
`${params.pts}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (params.k !== undefined || params.d !== undefined) {
|
||||||
|
parts.push(format.kd(params.k ?? 0, params.d ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = format.stats(
|
||||||
|
params.atk || params.def || params.heal
|
||||||
|
? { atk: params.atk, def: params.def, heal: params.heal }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
if (stats) parts.push(stats);
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Bringer formatters ────────────────────────────────────────────────────────
|
// ─── Bringer formatters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function bringerDisplay(n: Nation): string {
|
function bringerDisplay(n: Nation): string {
|
||||||
|
|
@ -161,11 +259,48 @@ export const format = {
|
||||||
score,
|
score,
|
||||||
emoji,
|
emoji,
|
||||||
date,
|
date,
|
||||||
|
number: {
|
||||||
|
abbrev: abbrev,
|
||||||
|
colored: colorNumber
|
||||||
|
},
|
||||||
wrank: {
|
wrank: {
|
||||||
rank: wrankRank,
|
rank: wrankRank,
|
||||||
delta: wrankDelta,
|
delta: wrankDelta,
|
||||||
full: wrankFull,
|
full: wrankFull,
|
||||||
noRank: wrankNoRank,
|
noRank: wrankNoRank,
|
||||||
|
row(entry: WRankEntry|null, tgGoal: number, context: { nationHasRank: boolean; nationHasDelta: boolean }): string {
|
||||||
|
if (!entry || entry.currentRank === 0) {
|
||||||
|
if (!context.nationHasRank) return "";
|
||||||
|
return format.wrank.noRank({ delta: context.nationHasDelta });
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsHolder = entry.previousRank === undefined && context.nationHasDelta;
|
||||||
|
return format.wrank.rank(entry, tgGoal) +
|
||||||
|
format.wrank.delta(entry, { brackets: true, placeholder: needsHolder });
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
scoreBold(pts: number): string {
|
||||||
|
return `**${pts}**`;
|
||||||
|
},
|
||||||
|
|
||||||
|
scoreEmoji(pts: number): string {
|
||||||
|
return String(pts).split("").map((d) => Emoji.get(`wrank_${d}`) || d).join("");
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Pad a name with invisible Hangul filler characters (ㅤ) to a target
|
||||||
|
* character length, for approximate column alignment in proportional fonts.
|
||||||
|
* EXPERIMENTAL — alignment depends on Discord's font rendering and varies
|
||||||
|
* by character width (e.g. »« symbols aren't the same width as letters).
|
||||||
|
*/
|
||||||
|
padName(name: string, targetLength: number): string {
|
||||||
|
const FILLER = "ㅤ";
|
||||||
|
const diff = targetLength - name.length;
|
||||||
|
return diff > 0 ? name + FILLER.repeat(diff) : name;
|
||||||
|
},
|
||||||
|
scoreSubmitLine,
|
||||||
|
statText,
|
||||||
|
tgCount: formatTGCount,
|
||||||
|
stats: formatStats,
|
||||||
|
kd: formatKd,
|
||||||
bringer: bringerDisplay,
|
bringer: bringerDisplay,
|
||||||
};
|
};
|
||||||
197
src/systems/leaderboard.ts
Normal file
197
src/systems/leaderboard.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
/**
|
||||||
|
* Leaderboard — manages weekly W.Rank leaderboard posts.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { Leaderboard } from "@systems/leaderboard";
|
||||||
|
*
|
||||||
|
* await Leaderboard.update({ client }) // called after score submission
|
||||||
|
* await Leaderboard.post({ client }) // manual post/edit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client, EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, Character } from "@types";
|
||||||
|
import { WRank, WRankEntry, WRankWeek } from "@systems/wrank";
|
||||||
|
import { Score } from "@systems/score";
|
||||||
|
import { Leaves } from "@systems/leaves";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { PersistentMessage } from "@systems/persistent-message";
|
||||||
|
import { Nations } from "@systems/nations";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { TGStats, WRankPosition } from "@types";
|
||||||
|
import { Bringer } from "@systems/bringer";
|
||||||
|
import { DiscordClient } from "@src/discord/client";
|
||||||
|
import { LeaderboardUI } from "@ui/leaderboard";
|
||||||
|
import { buildHighlightsEmbed } from "@ui/leaderboard/highlights";
|
||||||
|
|
||||||
|
const log = Logger.for("Leaderboard");
|
||||||
|
|
||||||
|
PersistentMessage.registerSlot({ store: "leaderboard", slot: "main" });
|
||||||
|
PersistentMessage.registerSlot({ store: "leaderboard", slot: "highlights" });
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
character: Character;
|
||||||
|
weeklyPts: number;
|
||||||
|
tgCount: number;
|
||||||
|
totalKills: number;
|
||||||
|
totalDeaths: number;
|
||||||
|
stats?: TGStats;
|
||||||
|
currentRank: number;
|
||||||
|
previousRank?: number;
|
||||||
|
leavesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row formatting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatLeaderboardRow(entry: LeaderboardEntry, goal: number): string {
|
||||||
|
const char = entry.character;
|
||||||
|
const wrank = format.wrank.rank({ currentRank: entry.currentRank, tgCount: entry.tgCount } as any, goal);
|
||||||
|
const delta = format.wrank.delta({ currentRank: entry.currentRank, previousRank: entry.previousRank, tgCount: entry.tgCount } as any, { brackets: true });
|
||||||
|
const classStr = Emoji.class(char.class.key) || char.class.key;
|
||||||
|
const charStr = `${classStr} ${char.level} ${char.name}`;
|
||||||
|
const pts = `📊 ${format.number.abbrev(entry.weeklyPts)}`;
|
||||||
|
const kd = entry.totalKills || entry.totalDeaths ? ` · ${format.kd(entry.totalKills, entry.totalDeaths)}` : "";
|
||||||
|
const stats = format.stats(entry.stats);
|
||||||
|
const tgs = format.tgCount(entry.tgCount, goal);
|
||||||
|
const bringer = Bringer.get({ nation: char.nation }) === char.name
|
||||||
|
? ` · ${format.bringer(char.nation)}`
|
||||||
|
: "";
|
||||||
|
const cockroach = entry.leavesCount > 0
|
||||||
|
? ` ${Leaves.formatIndicator({ characterName: char.name })}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
log.info(`char.class:`, char.class, `char.name:`, char.name);
|
||||||
|
|
||||||
|
return `${wrank}${delta} ${charStr}${bringer}${cockroach} — ${pts}${kd}${stats} · ${tgs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNationField(
|
||||||
|
nation: Nation,
|
||||||
|
entries: LeaderboardEntry[],
|
||||||
|
goal: number
|
||||||
|
): string {
|
||||||
|
if (entries.length === 0) return "—";
|
||||||
|
// Sort by weeklyPts descending
|
||||||
|
const sorted = [...entries].sort((a, b) => b.weeklyPts - a.weeklyPts);
|
||||||
|
return sorted.map((e) => formatLeaderboardRow(e, goal)).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data building ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildEntries(nation: Nation, week: WRankWeek): LeaderboardEntry[] {
|
||||||
|
const wRankEntries = WRank.entriesForNation(nation, week);
|
||||||
|
|
||||||
|
return wRankEntries.map((wr): LeaderboardEntry => {
|
||||||
|
const char = wr.character;
|
||||||
|
|
||||||
|
// Aggregate K/D and stats from all active slots this week
|
||||||
|
let totalKills = 0;
|
||||||
|
let totalDeaths = 0;
|
||||||
|
let totalAtk = 0;
|
||||||
|
let totalDef = 0;
|
||||||
|
let totalHeal = 0;
|
||||||
|
|
||||||
|
for (const historyKey of (week.scoreIndex[char.name] ?? [])) {
|
||||||
|
const score = Score.get({
|
||||||
|
character: char,
|
||||||
|
slot: TGKey.parse(historyKey as TGKey).slot,
|
||||||
|
historyKey: historyKey as TGKey,
|
||||||
|
});
|
||||||
|
if (!score) continue;
|
||||||
|
totalKills += score.k ?? 0;
|
||||||
|
totalDeaths += score.d ?? 0;
|
||||||
|
totalAtk += score.atk ?? 0;
|
||||||
|
totalDef += score.def ?? 0;
|
||||||
|
totalHeal += score.heal ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
character: char,
|
||||||
|
weeklyPts: wr.weeklyPoints,
|
||||||
|
tgCount: wr.tgCount,
|
||||||
|
totalKills,
|
||||||
|
totalDeaths,
|
||||||
|
stats: {
|
||||||
|
atk: totalAtk || undefined,
|
||||||
|
def: totalDef || undefined,
|
||||||
|
heal: totalHeal || undefined,
|
||||||
|
},
|
||||||
|
currentRank: wr.currentRank,
|
||||||
|
previousRank: wr.previousRank,
|
||||||
|
leavesCount: Leaves.countForChar({ characterName: char.name }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Leaderboard = {
|
||||||
|
/**
|
||||||
|
* Build and post/edit the leaderboard for the current week.
|
||||||
|
* Called after each score submission and on weekly reset.
|
||||||
|
*/
|
||||||
|
async update({ weekKey }: { weekKey?: string } = {}): Promise<void> {
|
||||||
|
const client = DiscordClient.get();
|
||||||
|
const channelId = Config.get({ section: "channels", key: "leaderboard" });
|
||||||
|
if (!channelId) { log.warn("leaderboard channel not configured"); return; }
|
||||||
|
|
||||||
|
const week = weekKey ? WRank.weekFromKey(weekKey) : WRank.currentWeek();
|
||||||
|
if (!week) { log.warn(`Week ${weekKey} not found`); return; }
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
...buildEntries(Nation.Capella, week),
|
||||||
|
...buildEntries(Nation.Procyon, week),
|
||||||
|
].map((e) => ({
|
||||||
|
...e,
|
||||||
|
position: { currentRank: e.currentRank, previousRank: e.previousRank },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mainEmbed = LeaderboardUI.buildEmbed(week, rows);
|
||||||
|
|
||||||
|
await PersistentMessage.updateSlot({
|
||||||
|
store: "leaderboard",
|
||||||
|
key: week.weekKey,
|
||||||
|
slot: "main",
|
||||||
|
embed: mainEmbed,
|
||||||
|
channelId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(`Leaderboard main updated for ${week.weekKey}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateHighlights({ weekKey }: { weekKey?: string } = {}): Promise<void> {
|
||||||
|
const client = DiscordClient.get();
|
||||||
|
const channelId = Config.get({ section: "channels", key: "leaderboard" });
|
||||||
|
if (!channelId) { log.warn("leaderboard channel not configured"); return; }
|
||||||
|
|
||||||
|
const week = weekKey ? WRank.weekFromKey(weekKey) : WRank.currentWeek();
|
||||||
|
if (!week) { log.warn(`Week ${weekKey} not found`); return; }
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
...buildEntries(Nation.Capella, week),
|
||||||
|
...buildEntries(Nation.Procyon, week),
|
||||||
|
].map((e) => ({
|
||||||
|
...e,
|
||||||
|
position: { currentRank: e.currentRank, previousRank: e.previousRank },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const highlightsEmbed = buildHighlightsEmbed(rows, week.weekKey);
|
||||||
|
|
||||||
|
await PersistentMessage.updateSlot({
|
||||||
|
store: "leaderboard",
|
||||||
|
key: week.weekKey,
|
||||||
|
slot: "highlights",
|
||||||
|
embed: highlightsEmbed,
|
||||||
|
channelId,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(`Leaderboard highlights updated for ${week.weekKey}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* PersistentMessage — manages Discord messages that need to be edited in place.
|
* PersistentMessage — manages Discord messages that need to be edited in place.
|
||||||
|
*
|
||||||
|
* Supports two modes:
|
||||||
|
* 1. Simple (legacy) — one message, one set of embeds, via .post()
|
||||||
|
* 2. Slotted — a message composed of multiple named embed "slots",
|
||||||
|
* each independently updatable via .updateSlot(). Other slots are
|
||||||
|
* reused from their last-saved snapshot (frozen) unless updated.
|
||||||
|
*
|
||||||
* Each store maps to a separate file in data/.message-ids/
|
* Each store maps to a separate file in data/.message-ids/
|
||||||
|
* Slot snapshots live in data/snapshots/{store}/{key}/{slot}.json
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage (legacy, unchanged):
|
||||||
* import { PersistentMessage } from "@systems/persistent-message";
|
* await PersistentMessage.post({ store: "leaderboard", key: "2026-W24", channelId, embeds: [embed], client });
|
||||||
*
|
*
|
||||||
* await PersistentMessage.post({
|
* Usage (slotted):
|
||||||
* store: "leaderboard",
|
* PersistentMessage.registerSlot({ store: "leaderboard", slot: "main" });
|
||||||
* key: "2026-W24",
|
* PersistentMessage.registerSlot({ store: "leaderboard", slot: "highlights" });
|
||||||
* channelId: "123456",
|
*
|
||||||
* embeds,
|
* await PersistentMessage.updateSlot({
|
||||||
* client,
|
* store: "leaderboard", key: "2026-W24", slot: "main",
|
||||||
|
* embed: mainEmbed, channelId, client,
|
||||||
* });
|
* });
|
||||||
*
|
|
||||||
* PersistentMessage.get({ store: "leaderboard", key: "2026-W24" }) // messageId | null
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { Client, EmbedBuilder, TextChannel } from "discord.js";
|
import { Client, EmbedBuilder, TextChannel } from "discord.js";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
|
|
@ -52,7 +60,21 @@
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
interface RegisterSlotParams {
|
||||||
|
store: MessageStore;
|
||||||
|
slot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateSlotParams {
|
||||||
|
store: MessageStore;
|
||||||
|
key: string;
|
||||||
|
slot: string;
|
||||||
|
embed: EmbedBuilder;
|
||||||
|
channelId: string;
|
||||||
|
client: Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers — messageId storage (unchanged) ─────────────────────────────────
|
||||||
|
|
||||||
function storePath(store: MessageStore): string {
|
function storePath(store: MessageStore): string {
|
||||||
return Paths.data(".message-ids", `${store}.json`);
|
return Paths.data(".message-ids", `${store}.json`);
|
||||||
|
|
@ -66,39 +88,46 @@
|
||||||
Store.write(storePath(store), data);
|
Store.write(storePath(store), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers — slot registry + snapshots ─────────────────────────────────────
|
||||||
|
|
||||||
|
const _slotRegistry = new Map<MessageStore, string[]>();
|
||||||
|
|
||||||
|
function slotSnapshotPath(store: MessageStore, key: string, slot: string): string {
|
||||||
|
return Paths.data("snapshots", store, key, `${slot}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSlotSnapshot(store: MessageStore, key: string, slot: string): any | null {
|
||||||
|
return Store.read(slotSnapshotPath(store, key, slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSlotSnapshot(store: MessageStore, key: string, slot: string, data: any): void {
|
||||||
|
const filePath = slotSnapshotPath(store, key, slot);
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
Store.write(filePath, data);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const PersistentMessage = {
|
export const PersistentMessage = {
|
||||||
/**
|
// ─── Legacy / simple API (unchanged) ─────────────────────────────────────
|
||||||
* Get the stored messageId for a key in a store.
|
|
||||||
*/
|
|
||||||
get({ store, key }: GetParams): string | null {
|
get({ store, key }: GetParams): string | null {
|
||||||
return readStore(store)[key] ?? null;
|
return readStore(store)[key] ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a messageId for a key.
|
|
||||||
*/
|
|
||||||
set({ store, key, messageId }: SetParams): void {
|
set({ store, key, messageId }: SetParams): void {
|
||||||
const data = readStore(store);
|
const data = readStore(store);
|
||||||
data[key] = messageId;
|
data[key] = messageId;
|
||||||
writeStore(store, data);
|
writeStore(store, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a stored messageId.
|
|
||||||
*/
|
|
||||||
delete({ store, key }: DeleteParams): void {
|
delete({ store, key }: DeleteParams): void {
|
||||||
const data = readStore(store);
|
const data = readStore(store);
|
||||||
delete data[key];
|
delete data[key];
|
||||||
writeStore(store, data);
|
writeStore(store, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Post or edit a persistent message.
|
|
||||||
* If a messageId exists for the key, edits the existing message.
|
|
||||||
* If not, posts a new message and stores the messageId.
|
|
||||||
*/
|
|
||||||
async post({ store, key, channelId, embeds, client }: PostParams): Promise<void> {
|
async post({ store, key, channelId, embeds, client }: PostParams): Promise<void> {
|
||||||
const channel = await client.channels.fetch(channelId) as TextChannel;
|
const channel = await client.channels.fetch(channelId) as TextChannel;
|
||||||
const messageId = PersistentMessage.get({ store, key });
|
const messageId = PersistentMessage.get({ store, key });
|
||||||
|
|
@ -112,7 +141,7 @@
|
||||||
log.info(`Edited ${store}/${key} (${messageId})`);
|
log.info(`Edited ${store}/${key} (${messageId})`);
|
||||||
return;
|
return;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log.warn(`Could not edit ${messageId}, posting new: ${err.message}`);
|
log.warn(`Could not edit ${messageId}: ${err.message} — posting new`);
|
||||||
PersistentMessage.delete({ store, key });
|
PersistentMessage.delete({ store, key });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,10 +151,74 @@
|
||||||
log.info(`Posted ${store}/${key} (${msg.id})`);
|
log.info(`Posted ${store}/${key} (${msg.id})`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* List all keys in a store.
|
|
||||||
*/
|
|
||||||
list({ store }: { store: MessageStore }): string[] {
|
list({ store }: { store: MessageStore }): string[] {
|
||||||
return Object.keys(readStore(store));
|
return Object.keys(readStore(store));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Slotted API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a named embed slot for a store. Determines render order —
|
||||||
|
* slots render in the order they were registered.
|
||||||
|
* Call once at startup per slot (idempotent — re-registering is a no-op).
|
||||||
|
*/
|
||||||
|
registerSlot({ store, slot }: RegisterSlotParams): void {
|
||||||
|
const slots = _slotRegistry.get(store) ?? [];
|
||||||
|
if (!slots.includes(slot)) {
|
||||||
|
slots.push(slot);
|
||||||
|
_slotRegistry.set(store, slots);
|
||||||
|
log.debug(`Registered slot: ${store}/${slot}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List registered slots for a store, in render order.
|
||||||
|
*/
|
||||||
|
slots({ store }: { store: MessageStore }): string[] {
|
||||||
|
return _slotRegistry.get(store) ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single embed slot. Rebuilds that slot's embed and saves its
|
||||||
|
* snapshot. Other registered slots are reused from their last-saved
|
||||||
|
* snapshot (frozen) — they are NOT recalculated.
|
||||||
|
* If a slot has never been saved, it's skipped (not included) until
|
||||||
|
* its first update.
|
||||||
|
*/
|
||||||
|
async updateSlot({ store, key, slot, embed, channelId, client }: UpdateSlotParams): Promise<void> {
|
||||||
|
const registered = _slotRegistry.get(store) ?? [];
|
||||||
|
if (!registered.includes(slot)) {
|
||||||
|
log.warn(`Slot "${slot}" not registered for store "${store}" — registering now`);
|
||||||
|
PersistentMessage.registerSlot({ store, slot });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save this slot's new data
|
||||||
|
const json = embed.toJSON();
|
||||||
|
writeSlotSnapshot(store, key, slot, json);
|
||||||
|
|
||||||
|
// Compose final embeds[] in registration order
|
||||||
|
const orderedSlots = _slotRegistry.get(store) ?? [slot];
|
||||||
|
const embeds: EmbedBuilder[] = [];
|
||||||
|
|
||||||
|
for (const s of orderedSlots) {
|
||||||
|
if (s === slot) {
|
||||||
|
embeds.push(embed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const snapshot = readSlotSnapshot(store, key, s);
|
||||||
|
if (snapshot) embeds.push(new EmbedBuilder(snapshot));
|
||||||
|
// else — slot never populated yet, skip it silently
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`updateSlot: store=${store} key=${key} slot=${slot} totalEmbeds=${embeds.length}`);
|
||||||
|
|
||||||
|
await PersistentMessage.post({ store, key, channelId, embeds, client });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last-saved snapshot for a slot, if any.
|
||||||
|
*/
|
||||||
|
getSlotSnapshot({ store, key, slot }: { store: MessageStore; key: string; slot: string }): any | null {
|
||||||
|
return readSlotSnapshot(store, key, slot);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
|
||||||
import { Character, UserKey, CharName } from "@types";
|
import { Character, UserKey, CharName } from "@types";
|
||||||
import { Paths } from "@helpers/paths";
|
import { Paths } from "@helpers/paths";
|
||||||
|
|
||||||
|
|
@ -37,11 +36,11 @@
|
||||||
*/
|
*/
|
||||||
find(charName: CharName): Character | null {
|
find(charName: CharName): Character | null {
|
||||||
const chars = loadChars();
|
const chars = loadChars();
|
||||||
for (const data of Object.values(chars)) {
|
for (const [ownerKey, data] of Object.entries(chars)) {
|
||||||
const found = data.characters?.find(
|
const found = data.characters?.find(
|
||||||
(c) => c.name.toLowerCase() === charName.toLowerCase()
|
(c) => c.name.toLowerCase() === charName.toLowerCase()
|
||||||
);
|
);
|
||||||
if (found) return found;
|
if (found) return { ...found, ownerKey } as Character;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -56,9 +55,10 @@
|
||||||
*/
|
*/
|
||||||
findForUser(userKey: UserKey, charName: CharName): Character | null {
|
findForUser(userKey: UserKey, charName: CharName): Character | null {
|
||||||
const chars = loadChars();
|
const chars = loadChars();
|
||||||
return chars[userKey]?.characters?.find(
|
const found = chars[userKey]?.characters?.find(
|
||||||
(c) => c.name.toLowerCase() === charName.toLowerCase()
|
(c) => c.name.toLowerCase() === charName.toLowerCase()
|
||||||
) ?? null;
|
);
|
||||||
|
return found ? { ...found, ownerKey: userKey } as Character : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
*/
|
*/
|
||||||
forUser(userKey: UserKey): Character[] {
|
forUser(userKey: UserKey): Character[] {
|
||||||
const chars = loadChars();
|
const chars = loadChars();
|
||||||
return chars[userKey]?.characters ?? [];
|
return (chars[userKey]?.characters ?? []).map((c) => ({ ...c, ownerKey: userKey } as Character));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
if (ownerKey === userKey) continue;
|
if (ownerKey === userKey) continue;
|
||||||
for (const char of data.characters ?? []) {
|
for (const char of data.characters ?? []) {
|
||||||
if (char.sharedWith?.includes(userKey)) {
|
if (char.sharedWith?.includes(userKey)) {
|
||||||
result.push({ char, ownerKey });
|
result.push({ char: { ...char, ownerKey } as Character, ownerKey });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
259
src/systems/result.ts
Normal file
259
src/systems/result.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
/**
|
||||||
|
* Result — manages TG result posts to #results channel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, Character, ClassKey, CLASSES, UserKey } from "@types";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Score, TGScore } from "@systems/score";
|
||||||
|
import { Attendance } from "@systems/attendance";
|
||||||
|
import { WRank, WRankEntry } from "@systems/wrank";
|
||||||
|
import { Bringer } from "@systems/bringer";
|
||||||
|
import { Leaves } from "@systems/leaves";
|
||||||
|
import { CharacterRegistry } from "@registry/character-registry";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { PersistentMessage } from "@systems/persistent-message";
|
||||||
|
import { Store } from "@systems/store";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
import { DiscordClient } from "@discord/client";
|
||||||
|
import { ResultUI, ResultRow as UIResultRow } from "@ui/result";
|
||||||
|
|
||||||
|
const log = Logger.for("result");
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NationContext {
|
||||||
|
nationHasRank: boolean;
|
||||||
|
nationHasDelta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultRow {
|
||||||
|
character: Character;
|
||||||
|
score?: TGScore;
|
||||||
|
position?: { currentRank: number; previousRank?: number };
|
||||||
|
leavesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildNationContext(rows: ResultRow[]): NationContext {
|
||||||
|
return {
|
||||||
|
nationHasRank: rows.some((r) => r.position && r.position.currentRank !== 0),
|
||||||
|
nationHasDelta: rows.some((r) => r.position?.previousRank !== undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row formatting ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatResultRow(row: ResultRow, context: NationContext, goal: number): string {
|
||||||
|
const char = row.character;
|
||||||
|
|
||||||
|
// W.Rank
|
||||||
|
const wrEntry: WRankEntry | null = row.position ? {
|
||||||
|
character: char,
|
||||||
|
weeklyPoints: row.score?.pts ?? 0,
|
||||||
|
tgCount: 1,
|
||||||
|
currentRank: row.position.currentRank,
|
||||||
|
previousRank: row.position.previousRank,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const wrank = format.wrank.row(wrEntry, goal, context);
|
||||||
|
|
||||||
|
// Character
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const classStr = classKey ? (Emoji.class(classKey) || classKey) : "?";
|
||||||
|
const charStr = `${classStr} ${char.level} ${char.name}`;
|
||||||
|
|
||||||
|
// Bringer
|
||||||
|
const bringer = Bringer.get({ nation: char.nation }) === char.name
|
||||||
|
? ` · ${format.bringer(char.nation)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Cockroach
|
||||||
|
const cockroach = row.leavesCount > 0
|
||||||
|
? ` ${Leaves.formatIndicator({ characterName: char.name })}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!row.score) {
|
||||||
|
return `${wrank} ${charStr}${bringer}${cockroach} — —`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const pts = `${scoreEmoji} ${format.scoreBold(row.score.pts)}`;
|
||||||
|
const kd = (row.score.k || row.score.d)
|
||||||
|
? ` · ${format.kd(row.score.k ?? 0, row.score.d ?? 0)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Stats on new line with indent
|
||||||
|
const statsStr = format.stats(
|
||||||
|
row.score.atk || row.score.def || row.score.heal
|
||||||
|
? { atk: row.score.atk, def: row.score.def, heal: row.score.heal }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
const mainLine = `${wrank} ${charStr}${bringer}${cockroach} — ${pts}${kd}`;
|
||||||
|
const statsLine = statsStr ? `\u3000${statsStr}` : "";
|
||||||
|
|
||||||
|
return statsLine ? `${mainLine}\n${statsLine}` : mainLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nation field ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildNationField(rows: ResultRow[], goal: number): string {
|
||||||
|
if (rows.length === 0) return "—";
|
||||||
|
const context = buildNationContext(rows);
|
||||||
|
|
||||||
|
// Sort by score descending (per nation since rows are already filtered)
|
||||||
|
const sorted = [...rows].sort((a, b) => (b.score?.pts ?? 0) - (a.score?.pts ?? 0));
|
||||||
|
|
||||||
|
return sorted.map((r) => formatResultRow(r, context, goal)).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Embed ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildResultEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const { date, slot } = TGKey.parse(historyKey);
|
||||||
|
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
const proK = procyonRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const proD = procyonRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: `${capellaEmoji} Capella${(capK || capD) ? ` — ${format.kd(capK, capD)}` : ""}`,
|
||||||
|
value: buildNationField(capellaRows, goal) || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: false },
|
||||||
|
{
|
||||||
|
name: `${procyonEmoji} Procyon${(proK || proD) ? ` — ${format.kd(proK, proD)}` : ""}`,
|
||||||
|
value: buildNationField(procyonRows, goal) || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildRows(historyKey: TGKey): ResultRow[] {
|
||||||
|
const { slot, date } = TGKey.parse(historyKey);
|
||||||
|
let players: UserKey[] = Attendance.players(historyKey);
|
||||||
|
|
||||||
|
if (players.length === 0) {
|
||||||
|
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
|
||||||
|
if (history?.scores) {
|
||||||
|
players = [...new Set(history.scores.map((s: TGScore) => s.userKey))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekKey = WRank.weekKey(new Date(date));
|
||||||
|
const addedUsers = new Set<UserKey>();
|
||||||
|
const rows: ResultRow[] = [];
|
||||||
|
|
||||||
|
for (const userKey of players) {
|
||||||
|
const chars = CharacterRegistry.forUser(userKey);
|
||||||
|
|
||||||
|
for (const char of chars) {
|
||||||
|
const score = Score.get({ character: char, slot, historyKey });
|
||||||
|
if (!score) continue;
|
||||||
|
|
||||||
|
const wrEntry = WRank.entry(char.name, char.nation, weekKey);
|
||||||
|
const position = score.wRankAtSubmission
|
||||||
|
? {
|
||||||
|
currentRank: score.wRankAtSubmission.rank,
|
||||||
|
previousRank: score.wRankAtSubmission.rank - score.wRankAtSubmission.delta,
|
||||||
|
}
|
||||||
|
: wrEntry
|
||||||
|
? { currentRank: wrEntry.currentRank, previousRank: wrEntry.previousRank }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
character: char,
|
||||||
|
score,
|
||||||
|
position,
|
||||||
|
leavesCount: Leaves.countForChar({ characterName: char.name }),
|
||||||
|
});
|
||||||
|
addedUsers.add(userKey);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!addedUsers.has(userKey)) {
|
||||||
|
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
|
||||||
|
const score = history?.scores.find((s: TGScore) => s.userKey === userKey);
|
||||||
|
if (score) {
|
||||||
|
const foundChar = CharacterRegistry.find(score.characterName);
|
||||||
|
const classKey = (typeof score.class === "object"
|
||||||
|
? (score.class as any)?.key
|
||||||
|
: score.class) as ClassKey;
|
||||||
|
const char: Character = foundChar ?? {
|
||||||
|
name: score.characterName,
|
||||||
|
class: CLASSES[classKey] ?? { key: classKey, name: classKey, shortName: classKey },
|
||||||
|
level: 0,
|
||||||
|
nation: score.nation,
|
||||||
|
ownerKey: score.userKey,
|
||||||
|
};
|
||||||
|
const wrEntry = WRank.entry(char.name, char.nation, weekKey);
|
||||||
|
const position = score.wRankAtSubmission
|
||||||
|
? {
|
||||||
|
currentRank: score.wRankAtSubmission.rank,
|
||||||
|
previousRank: score.wRankAtSubmission.rank - score.wRankAtSubmission.delta,
|
||||||
|
}
|
||||||
|
: wrEntry
|
||||||
|
? { currentRank: wrEntry.currentRank, previousRank: wrEntry.previousRank }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
rows.push({ character: char, score, position, leavesCount: 0 });
|
||||||
|
addedUsers.add(userKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Result = {
|
||||||
|
async post({ historyKey }: { historyKey: TGKey }): Promise<void> {
|
||||||
|
const channelId = Config.get({ section: "channels", key: "results" });
|
||||||
|
if (!channelId) { log.warn("results channel not configured"); return; }
|
||||||
|
|
||||||
|
const client = DiscordClient.get();
|
||||||
|
const rows = buildRows(historyKey);
|
||||||
|
|
||||||
|
log.debug(`Building result for ${historyKey} — ${rows.length} rows`);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
log.warn(`No data for ${historyKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = ResultUI.buildEmbed(historyKey, rows as any);
|
||||||
|
|
||||||
|
await PersistentMessage.post({
|
||||||
|
store: "results",
|
||||||
|
key: historyKey,
|
||||||
|
channelId,
|
||||||
|
embeds: [embed],
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(`Result posted for ${historyKey}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -173,3 +173,34 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RuntimeEvent =
|
||||||
|
| "scoreSubmitted"
|
||||||
|
| "pollLocked"
|
||||||
|
| "pollConfirmed"
|
||||||
|
| "weekReset"
|
||||||
|
| "allScoresSubmitted";
|
||||||
|
|
||||||
|
type EventHandler<T = any> = (payload: T) => void | Promise<void>;
|
||||||
|
|
||||||
|
const _eventHandlers = new Map<RuntimeEvent, EventHandler[]>();
|
||||||
|
|
||||||
|
export const RuntimeEvents = {
|
||||||
|
on<T = any>(event: RuntimeEvent, handler: EventHandler<T>): void {
|
||||||
|
if (!_eventHandlers.has(event)) _eventHandlers.set(event, []);
|
||||||
|
_eventHandlers.get(event)!.push(handler as EventHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
async emit<T = any>(event: RuntimeEvent, payload?: T): Promise<void> {
|
||||||
|
const handlers = _eventHandlers.get(event) ?? [];
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
await handler(payload);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[RuntimeEvents] Handler error for ${event}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
29
src/systems/scheduler/midnight-results.ts
Normal file
29
src/systems/scheduler/midnight-results.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Midnight results — post any unposted TG results at midnight.
|
||||||
|
* Handles cases where not all scores were submitted before midnight.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from "discord.js";
|
||||||
|
import { ScheduledJob } from "./types";
|
||||||
|
|
||||||
|
export const job: ScheduledJob = {
|
||||||
|
name: "midnight-results",
|
||||||
|
cron: "0 0 * * *",
|
||||||
|
async run(client: Client) {
|
||||||
|
const { Attendance } = require("@systems/attendance");
|
||||||
|
const { Result } = require("@systems/result");
|
||||||
|
const { PersistentMessage } = require("@systems/persistent-message");
|
||||||
|
|
||||||
|
const allKeys = Attendance.all();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
// Post results for yesterday's TGs that weren't auto-posted
|
||||||
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
for (const historyKey of allKeys) {
|
||||||
|
if (!historyKey.startsWith(yesterday)) continue;
|
||||||
|
const existing = PersistentMessage.get({ store: "results", key: historyKey });
|
||||||
|
if (existing) continue; // already posted
|
||||||
|
await Result.post({ historyKey });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
import { Paths } from "@helpers/paths";
|
import { Paths } from "@helpers/paths";
|
||||||
import { TGKey } from "@systems/tg-key";
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { RuntimeEvents } from "@systems/runtime";
|
||||||
|
|
||||||
export interface TGScore {
|
export interface TGScore {
|
||||||
userKey: UserKey;
|
userKey: UserKey;
|
||||||
|
|
@ -64,12 +65,13 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||||
/**
|
/**
|
||||||
* Get a score for a character in a specific TG.
|
* Get a score for a character in a specific TG.
|
||||||
*/
|
*/
|
||||||
get({ character, slot }: {
|
get({ character, slot, historyKey }: {
|
||||||
character: Character;
|
character: Character;
|
||||||
slot: SlotHour;
|
slot: SlotHour;
|
||||||
|
historyKey?: TGKey;
|
||||||
}): TGScore | null {
|
}): TGScore | null {
|
||||||
const historyKey = TGKey.current({ slot });
|
const key = historyKey ?? TGKey.current({ slot });
|
||||||
const history = loadHistory(historyKey);
|
const history = loadHistory(key);
|
||||||
return history.scores.find(
|
return history.scores.find(
|
||||||
(s) => s.userKey === character.ownerKey && s.characterName === character.name
|
(s) => s.userKey === character.ownerKey && s.characterName === character.name
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
@ -112,7 +114,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||||
* Submit a score for a character.
|
* Submit a score for a character.
|
||||||
* Handles W.Rank snapshot at submission time.
|
* Handles W.Rank snapshot at submission time.
|
||||||
*/
|
*/
|
||||||
submit({ character, borrowedFrom, pts, k, d, atk, def, heal, slot, submittedByOfficer }: {
|
async submit({ character, borrowedFrom, pts, k, d, atk, def, heal, slot, submittedByOfficer }: {
|
||||||
character: Character;
|
character: Character;
|
||||||
borrowedFrom?: UserKey;
|
borrowedFrom?: UserKey;
|
||||||
pts: number;
|
pts: number;
|
||||||
|
|
@ -123,7 +125,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||||
heal?: number;
|
heal?: number;
|
||||||
slot: SlotHour;
|
slot: SlotHour;
|
||||||
submittedByOfficer?: boolean;
|
submittedByOfficer?: boolean;
|
||||||
}): void {
|
}): Promise<void> {
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
const historyKey = TGKey.current({ slot });
|
const historyKey = TGKey.current({ slot });
|
||||||
const history = loadHistory(historyKey);
|
const history = loadHistory(historyKey);
|
||||||
|
|
@ -173,5 +175,6 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||||
pts,
|
pts,
|
||||||
historyKey
|
historyKey
|
||||||
);
|
);
|
||||||
|
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -83,9 +83,9 @@ export function submitScore(sub: ScoreSubmission): void {
|
||||||
pts: sub.pts,
|
pts: sub.pts,
|
||||||
k: sub.k,
|
k: sub.k,
|
||||||
d: sub.d,
|
d: sub.d,
|
||||||
atk: sub.atk,
|
stats: sub.atk !== undefined || sub.def !== undefined || sub.heal !== undefined
|
||||||
def: sub.def,
|
? { atk: sub.atk, def: sub.def, heal: sub.heal }
|
||||||
heal: sub.heal,
|
: undefined,
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
slot: sub.slot,
|
slot: sub.slot,
|
||||||
date,
|
date,
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,10 @@ export const WRank = {
|
||||||
return _data[weekKey] ?? null;
|
return _data[weekKey] ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
allWeeks(): WRankData {
|
||||||
|
return _data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Score recording ──────────────────────────────────────────────────────────
|
// ── Score recording ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
recordScore(
|
recordScore(
|
||||||
|
|
@ -188,16 +192,16 @@ export const WRank = {
|
||||||
|
|
||||||
// ── Entry lookup ─────────────────────────────────────────────────────────────
|
// ── Entry lookup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
entry(characterName: CharName, nation: Nation): WRankEntry | null {
|
entry(characterName: CharName, nation: Nation, weekKey?: string): WRankEntry | null {
|
||||||
const week = WRank.currentWeek();
|
const week = weekKey ? (_data[weekKey] ?? null) : WRank.currentWeek();
|
||||||
const list = week.entries[nation];
|
const list = week.entries[nation];
|
||||||
const raw = list.find((e) => e.characterName === characterName);
|
const raw = list.find((e) => e.characterName === characterName);
|
||||||
return raw ? hydrateEntry(raw) : null;
|
return raw ? hydrateEntry(raw) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
entriesForNation(nation: Nation): WRankEntry[] {
|
entriesForNation(nation: Nation, week?: WRankWeek): WRankEntry[] {
|
||||||
const week = WRank.currentWeek();
|
const _week = week ?? WRank.currentWeek();
|
||||||
return week.entries[nation].map(hydrateEntry);
|
return _week.entries[nation].map(hydrateEntry);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Snapshot ─────────────────────────────────────────────────────────────────
|
// ── Snapshot ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
17
src/types.ts
17
src/types.ts
|
|
@ -173,6 +173,8 @@ export interface PollState {
|
||||||
lockedYesKeys?: Set<UserKey>;
|
lockedYesKeys?: Set<UserKey>;
|
||||||
lockMessage?: string;
|
lockMessage?: string;
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
|
called?: boolean;
|
||||||
|
calledAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,9 +200,7 @@ export interface TGScore {
|
||||||
pts: number;
|
pts: number;
|
||||||
k?: number;
|
k?: number;
|
||||||
d?: number;
|
d?: number;
|
||||||
atk?: number;
|
stats?: TGStats;
|
||||||
def?: number;
|
|
||||||
heal?: number;
|
|
||||||
submittedAt: string; // ISO timestamp
|
submittedAt: string; // ISO timestamp
|
||||||
slot: number; // TG hour
|
slot: number; // TG hour
|
||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
|
|
@ -208,6 +208,12 @@ export interface TGScore {
|
||||||
playedBy?: string; // userKey of who actually played (if borrowed)
|
playedBy?: string; // userKey of who actually played (if borrowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TGStats {
|
||||||
|
atk?: number;
|
||||||
|
def?: number;
|
||||||
|
heal?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── TG Result ───────────────────────────────────────────────────────────────
|
// ─── TG Result ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface NationKD {
|
export interface NationKD {
|
||||||
|
|
@ -251,6 +257,11 @@ export interface TGResult {
|
||||||
// activeCharacter: Character | null;
|
// activeCharacter: Character | null;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
export interface WRankPosition {
|
||||||
|
currentRank: number;
|
||||||
|
previousRank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Bringer ─────────────────────────────────────────────────────────────────
|
// ─── Bringer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface BringerState {
|
export interface BringerState {
|
||||||
|
|
|
||||||
133
src/ui/embed-helpers.ts
Normal file
133
src/ui/embed-helpers.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* EmbedHelpers — utilities for working within Discord embed limits
|
||||||
|
* and controlling inline field grid layout.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
*
|
||||||
|
* EmbedHelpers.chunkRows(rows)
|
||||||
|
* EmbedHelpers.addNationFields(embed, header, rows, inline)
|
||||||
|
* EmbedHelpers.addPerPlayerGrid(embed, [{ header, rows }, { header, rows }])
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface EmbedLike {
|
||||||
|
addFields: (...fields: { name: string; value: string; inline: boolean }[]) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chunking (for non-inline / single-field cases) ──────────────────────────
|
||||||
|
|
||||||
|
function chunkRows(rows: string[], separator: string = "\n", maxLen: number = 1024): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const candidate = current ? `${current}${separator}${row}` : row;
|
||||||
|
if (candidate.length > maxLen && current) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = row;
|
||||||
|
} else {
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) chunks.push(current);
|
||||||
|
console.log(`[EmbedHelpers] chunkRows: ${rows.length} rows -> ${chunks.length} chunk(s), lengths: ${chunks.map(c => c.length).join(", ")}`);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNationFields(
|
||||||
|
embed: EmbedLike,
|
||||||
|
header: string,
|
||||||
|
rows: string[],
|
||||||
|
inline: boolean = false
|
||||||
|
): void {
|
||||||
|
const chunks = chunkRows(rows);
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
embed.addFields({ name: header, value: "—", inline });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.forEach((chunk, i) => {
|
||||||
|
embed.addFields({
|
||||||
|
name: i === 0 ? header : "\u200b",
|
||||||
|
value: chunk,
|
||||||
|
inline,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-player grid ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render two columns as a clean 2-column grid, one player per field row.
|
||||||
|
* The nation header is embedded as bold text at the top of the first
|
||||||
|
* player's field (not a separate field) to avoid an extra header-to-content gap.
|
||||||
|
*/
|
||||||
|
function addPerPlayerGrid(
|
||||||
|
embed: EmbedLike,
|
||||||
|
columns: { header: string; rows: string[] }[]
|
||||||
|
): void {
|
||||||
|
if (columns.length !== 2) {
|
||||||
|
for (const col of columns) {
|
||||||
|
addNationFields(embed, col.header, col.rows, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [left, right] = columns;
|
||||||
|
const maxLen = Math.max(left.rows.length, right.rows.length, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
const leftRow = left.rows[i];
|
||||||
|
const rightRow = right.rows[i];
|
||||||
|
|
||||||
|
const leftValue = i === 0
|
||||||
|
? `**${left.header}**\n${leftRow ?? "—"}`
|
||||||
|
: (leftRow ?? "\u200b");
|
||||||
|
|
||||||
|
const rightValue = i === 0
|
||||||
|
? `**${right.header}**\n${rightRow ?? "—"}`
|
||||||
|
: (rightRow ?? "\u200b");
|
||||||
|
|
||||||
|
embed.addFields(
|
||||||
|
{ name: "\u200b", value: leftValue, inline: true },
|
||||||
|
{ name: "\u200b", value: rightValue, inline: true },
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single-column per-player fields ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single-column list, one player per field (no chunking needed —
|
||||||
|
* each field only holds one row, well under the 1024 char limit regardless
|
||||||
|
* of padding/content length). Header embedded as bold text in the first
|
||||||
|
* player's field to avoid an extra header-to-content gap.
|
||||||
|
*/
|
||||||
|
function addPerPlayerColumn(
|
||||||
|
embed: EmbedLike,
|
||||||
|
header: string,
|
||||||
|
rows: string[]
|
||||||
|
): void {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
embed.addFields({ name: header, value: "—", inline: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
const value = i === 0 ? `**${header}**\n\u200b\n${row}` : row;
|
||||||
|
embed.addFields({ name: "\u200b", value, inline: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const EmbedHelpers = {
|
||||||
|
chunkRows,
|
||||||
|
addNationFields,
|
||||||
|
addPerPlayerGrid,
|
||||||
|
addPerPlayerColumn,
|
||||||
|
};
|
||||||
126
src/ui/layout.ts
Normal file
126
src/ui/layout.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Layout — shared domain-aware formatting for all embed types.
|
||||||
|
* Wraps format.ts functions with business logic (Config, Bringer, Leaves).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { Layout } from "@ui/layout";
|
||||||
|
*
|
||||||
|
* Layout.wrank(entry, goal, context)
|
||||||
|
* Layout.tgCount(done, goal)
|
||||||
|
* Layout.kd(k, d)
|
||||||
|
* Layout.bringer(char)
|
||||||
|
* Layout.cockroach(char, historyKey)
|
||||||
|
* Layout.indicators(char, { historyKey })
|
||||||
|
* Layout.formatRow(template, tokens)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Character, Nation } from "@types";
|
||||||
|
import { WRankEntry } from "@systems/wrank";
|
||||||
|
import { Bringer } from "@systems/bringer";
|
||||||
|
import { Leaves } from "@systems/leaves";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
|
||||||
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NationContext {
|
||||||
|
nationHasRank: boolean;
|
||||||
|
nationHasDelta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const Layout = {
|
||||||
|
/**
|
||||||
|
* Format W.Rank prefix — rank + delta with placeholders for alignment.
|
||||||
|
*/
|
||||||
|
wrank(entry: WRankEntry | null, goal: number, context: NationContext): string {
|
||||||
|
return format.wrank.row(entry, goal, context);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format TG count as wrank emoji digits.
|
||||||
|
* done/goal — goal always gold, done turns gold when >= goal.
|
||||||
|
*/
|
||||||
|
tgCount(done: number, goal: number): string {
|
||||||
|
const doneEmoji = done >= goal
|
||||||
|
? (Emoji.get(`wrank_${done}_gold`) || `${done}`)
|
||||||
|
: (Emoji.get(`wrank_${done}`) || `${done}`);
|
||||||
|
const goalEmoji = Emoji.get(`wrank_${goal}_gold`) || `${goal}`;
|
||||||
|
return `${doneEmoji}/${goalEmoji}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format K/D — returns empty string if both zero.
|
||||||
|
*/
|
||||||
|
kd(k: number, d: number): string {
|
||||||
|
return (k || d) ? format.kd(k, d) : "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringer indicator — returns " · {bringer emoji}" if char is Bringer.
|
||||||
|
*/
|
||||||
|
bringer(char: Character): string {
|
||||||
|
const bringer = Bringer.get({ nation: char.nation });
|
||||||
|
return bringer === char.name ? ` · ${format.bringer(char.nation)}` : "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cockroach indicator — returns " {cockroach}{count}" if char left that TG.
|
||||||
|
*/
|
||||||
|
cockroach(char: Character, historyKey?: TGKey): string {
|
||||||
|
if (!historyKey) return "";
|
||||||
|
if (!Leaves.hasLeft({ characterName: char.name, historyKey })) return "";
|
||||||
|
return ` ${Leaves.formatIndicator({ characterName: char.name })}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All indicators combined — bringer + cockroach.
|
||||||
|
*/
|
||||||
|
indicators(char: Character, opts: { historyKey?: TGKey } = {}): string {
|
||||||
|
return Layout.bringer(char) + Layout.cockroach(char, opts.historyKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a row from a template string with token replacement.
|
||||||
|
* Tokens: {wrank} {class} {level} {name} {score} {kd} {tgs} {stats}
|
||||||
|
* Unknown tokens are left as-is.
|
||||||
|
* Trailing empty tokens and extra spaces are cleaned up.
|
||||||
|
*/
|
||||||
|
formatRow(template: string, tokens: Record<string, string>): string {
|
||||||
|
return template
|
||||||
|
.replace(/\{(\w+)\}/g, (_, key) => tokens[key] ?? `{${key}}`)
|
||||||
|
.replace(/ +/g, " ") // only collapse regular ASCII spaces, not all \s
|
||||||
|
.trim();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build nation context from any rows that have a position.
|
||||||
|
*/
|
||||||
|
nationContext(rows: { position?: { currentRank: number; previousRank?: number } }[]): NationContext {
|
||||||
|
return {
|
||||||
|
nationHasRank: rows.some((r) => r.position && r.position.currentRank !== 0),
|
||||||
|
nationHasDelta: rows.some((r) => r.position?.previousRank !== undefined),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WRankEntry shape from a position object for use with format.wrank.row.
|
||||||
|
*/
|
||||||
|
wrankEntry(
|
||||||
|
char: Character,
|
||||||
|
position?: { currentRank: number; previousRank?: number },
|
||||||
|
weeklyPoints = 0,
|
||||||
|
tgCount = 0
|
||||||
|
): WRankEntry | null {
|
||||||
|
if (!position || position.currentRank === 0) return null;
|
||||||
|
return {
|
||||||
|
character: char,
|
||||||
|
weeklyPoints,
|
||||||
|
tgCount,
|
||||||
|
currentRank: position.currentRank,
|
||||||
|
previousRank: position.previousRank,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
70
src/ui/leaderboard/highlights.ts
Normal file
70
src/ui/leaderboard/highlights.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Leaderboard highlights — secondary embed with weekly standout stats.
|
||||||
|
* Most kills, most deaths, next bringer per nation, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation } from "@types";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { LeaderboardRow } from "./index";
|
||||||
|
|
||||||
|
function topByKills(rows: LeaderboardRow[]): LeaderboardRow | null {
|
||||||
|
return [...rows].sort((a, b) => b.totalKills - a.totalKills)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topByDeaths(rows: LeaderboardRow[]): LeaderboardRow | null {
|
||||||
|
return [...rows].sort((a, b) => b.totalDeaths - a.totalDeaths)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextBringerCandidate(rows: LeaderboardRow[], goal: number): LeaderboardRow | null {
|
||||||
|
// Rank 1 with goal TGs met is eligible — pick the rank-1 player if they qualify
|
||||||
|
const rank1 = rows.find((r) => r.position?.currentRank === 1);
|
||||||
|
if (!rank1) return null;
|
||||||
|
return rank1.tgCount >= goal ? rank1 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHighlightsEmbed(allRows: LeaderboardRow[], weekKey: string): EmbedBuilder {
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
|
||||||
|
const capellaRows = allRows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = allRows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const topKillsCapella = topByKills(capellaRows);
|
||||||
|
const topKillsProcyon = topByKills(procyonRows);
|
||||||
|
const topDeathsAll = topByDeaths(allRows);
|
||||||
|
|
||||||
|
// Storm Bringer -> Procyon, Luminous Bringer -> Capella
|
||||||
|
const nextLuminousBringer = nextBringerCandidate(capellaRows, goal); // Capella
|
||||||
|
const nextStormBringer = nextBringerCandidate(procyonRows, goal); // Procyon
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
const killEmoji = Emoji.get("wrank_down_1") || "⚔️";
|
||||||
|
const deathEmoji = Emoji.get("wrank_up_1") || "💀";
|
||||||
|
const stormEmoji = Emoji.get("storm_bringer") || "⚡";
|
||||||
|
const luminousEmoji = Emoji.get("luminous_bringer") || "🌟";
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (topKillsCapella && topKillsCapella.totalKills > 0) {
|
||||||
|
lines.push(`${killEmoji} Most Kills (${capellaEmoji}): **${topKillsCapella.character.name}** (${topKillsCapella.totalKills})`);
|
||||||
|
}
|
||||||
|
if (topKillsProcyon && topKillsProcyon.totalKills > 0) {
|
||||||
|
lines.push(`${killEmoji} Most Kills (${procyonEmoji}): **${topKillsProcyon.character.name}** (${topKillsProcyon.totalKills})`);
|
||||||
|
}
|
||||||
|
if (topDeathsAll && topDeathsAll.totalDeaths > 0) {
|
||||||
|
lines.push(`${deathEmoji} Most Deaths: **${topDeathsAll.character.name}** (${topDeathsAll.totalDeaths})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(""); // spacer
|
||||||
|
|
||||||
|
lines.push(`${luminousEmoji} Next Luminous Bringer (${capellaEmoji}): ${nextLuminousBringer ? `**${nextLuminousBringer.character.name}**` : "—"}`);
|
||||||
|
lines.push(`${stormEmoji} Next Storm Bringer (${procyonEmoji}): ${nextStormBringer ? `**${nextStormBringer.character.name}**` : "—"}`);
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("📊 Weekly Highlights")
|
||||||
|
.setColor(0x5865f2)
|
||||||
|
.setDescription(lines.join("\n"))
|
||||||
|
.setFooter({ text: `Highlights · ${weekKey}` });
|
||||||
|
}
|
||||||
121
src/ui/leaderboard/index.ts
Normal file
121
src/ui/leaderboard/index.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* LeaderboardUI — dispatcher for leaderboard embed layouts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const log = Logger.for("leaderboard-ui");
|
||||||
|
|
||||||
|
Runtime.phase("restore", () => restoreLeaderboardLayout(), { name: "LeaderboardUI.restoreLayout" });
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LeaderboardRow {
|
||||||
|
character: {
|
||||||
|
name: string;
|
||||||
|
class: any;
|
||||||
|
level: number;
|
||||||
|
nation: any;
|
||||||
|
ownerKey: string;
|
||||||
|
};
|
||||||
|
weeklyPts: number;
|
||||||
|
tgCount: number;
|
||||||
|
totalKills: number;
|
||||||
|
totalDeaths: number;
|
||||||
|
stats?: { atk?: number; def?: number; heal?: number };
|
||||||
|
position?: { currentRank: number; previousRank?: number };
|
||||||
|
leavesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardLayout {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder;
|
||||||
|
formatRow(row: LeaderboardRow, context: any): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _layouts = new Map<string, LeaderboardLayout>();
|
||||||
|
let _active: LeaderboardLayout | null = null;
|
||||||
|
|
||||||
|
export function registerLeaderboardLayout(layout: LeaderboardLayout): void {
|
||||||
|
_layouts.set(layout.name, layout);
|
||||||
|
if (!_active) _active = layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeaderboardLayout(obj: any): obj is LeaderboardLayout {
|
||||||
|
return obj?.name && obj?.description &&
|
||||||
|
typeof obj?.buildEmbed === "function" &&
|
||||||
|
typeof obj?.formatRow === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverLeaderboardLayouts(): void {
|
||||||
|
const dir = path.join(__dirname, "layouts");
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const mod = require(path.join(dir, file));
|
||||||
|
for (const exported of Object.values(mod)) {
|
||||||
|
if (isLeaderboardLayout(exported)) {
|
||||||
|
registerLeaderboardLayout(exported as LeaderboardLayout);
|
||||||
|
log.info(`Registered leaderboard layout: ${(exported as LeaderboardLayout).name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error(`Failed to load leaderboard layout ${file}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoverLeaderboardLayouts();
|
||||||
|
|
||||||
|
export function restoreLeaderboardLayout(): void {
|
||||||
|
const saved = Config.get({ section: "leaderboard", key: "layout" });
|
||||||
|
if (saved && _layouts.has(saved)) {
|
||||||
|
_active = _layouts.get(saved)!;
|
||||||
|
log.info(`Restored leaderboard layout: ${saved}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function activeLayout(): LeaderboardLayout {
|
||||||
|
const layout = _active ?? _layouts.values().next().value;
|
||||||
|
if (!layout) throw new Error("[LeaderboardUI] No layouts registered");
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LeaderboardUI = {
|
||||||
|
buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
return activeLayout().buildEmbed(week, rows);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatRow(row: LeaderboardRow, context: any): string {
|
||||||
|
return activeLayout().formatRow(row, context);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLayout(name: string): boolean {
|
||||||
|
const layout = _layouts.get(name);
|
||||||
|
if (!layout) return false;
|
||||||
|
_active = layout;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
layouts(): { name: string; description: string }[] {
|
||||||
|
return [..._layouts.values()].map((l) => ({ name: l.name, description: l.description }));
|
||||||
|
},
|
||||||
|
|
||||||
|
register: registerLeaderboardLayout,
|
||||||
|
};
|
||||||
86
src/ui/leaderboard/layouts/default.ts
Normal file
86
src/ui/leaderboard/layouts/default.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Default leaderboard layout — full detail (Option A).
|
||||||
|
* W.Rank + delta, pts, k/d, TG count per row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {level} {name}{indicators} — {score}{kd} · {tgFlag}{tgs}";
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
|
||||||
|
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
level: `${char.level}`,
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
score: `${Emoji.get("score") || "📊"} ${format.scoreBold(row.weeklyPts)}`,
|
||||||
|
kd: Layout.kd(row.totalKills, row.totalDeaths),
|
||||||
|
tgFlag: Emoji.get("tg_flag") || "🏁",
|
||||||
|
tgs: Layout.tgCount(row.tgCount, goal),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
|
||||||
|
// Sort by weeklyPts descending
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capK = capellaRows.reduce((s, r) => s + r.totalKills, 0);
|
||||||
|
const capD = capellaRows.reduce((s, r) => s + r.totalDeaths, 0);
|
||||||
|
const proK = procyonRows.reduce((s, r) => s + r.totalKills, 0);
|
||||||
|
const proD = procyonRows.reduce((s, r) => s + r.totalDeaths, 0);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
// .setTitle(`⚔️ W.Rank — ${week.weekKey} · Goal: ${goal} TGs`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: `${capellaEmoji} Capella${(capK || capD) ? ` — ${format.kd(capK, capD)}` : ""}`,
|
||||||
|
value: [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext)).join("\n") || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: false },
|
||||||
|
{
|
||||||
|
name: `${procyonEmoji} Procyon${(proK || proD) ? ` — ${format.kd(proK, proD)}` : ""}`,
|
||||||
|
value: [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext)).join("\n") || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setFooter({ text: `W.Rank · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "default",
|
||||||
|
description: "Full detail — W.Rank, pts, K/D, TG count",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
67
src/ui/leaderboard/layouts/horizontal-combined.ts
Normal file
67
src/ui/leaderboard/layouts/horizontal-combined.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Combined leaderboard layout — single ranked list across both nations,
|
||||||
|
* with nation indicator per row. No delta, rank only (gold when goal met).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{rank} {nation} {class} {name}{indicators} — {score}{kd} · [{tgs}]";
|
||||||
|
|
||||||
|
function formatRankOnly(currentRank: number, goalMet: boolean): string {
|
||||||
|
if (!currentRank || currentRank === 0) return "—";
|
||||||
|
const suffix = goalMet ? "_gold" : "";
|
||||||
|
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const goalMet = row.tgCount >= goal;
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
|
||||||
|
nation: Emoji.nation(char.nation),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
score: `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`,
|
||||||
|
kd: Layout.kd(row.totalKills, row.totalDeaths),
|
||||||
|
tgs: Layout.tgCount(row.tgCount, goal),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
const context = Layout.nationContext(rows);
|
||||||
|
const sorted = [...rows].sort(sortByPts);
|
||||||
|
const formatted = sorted.map((r) => formatRow(r, context));
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
EmbedHelpers.addNationFields(embed, "\u200b", formatted, false);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const combinedLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "combined",
|
||||||
|
description: "Single ranked list across both nations, rank only (no delta)",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
143
src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts
Normal file
143
src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/**
|
||||||
|
* Sequential-extra-stats leaderboard layout — based directly on the
|
||||||
|
* working "sequential" layout (full name/score/K-D/TG-count alignment
|
||||||
|
* via TextAlign), with an additional indented second line showing
|
||||||
|
* weekly atk/def/heal stats when present.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { TextAlign } from "@ui/text-align";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const log = Logger.for("sequential-extra-stats");
|
||||||
|
|
||||||
|
const TEMPLATE = "{rank} {class} {name}{indicators} {score} {kd} {tgs}";
|
||||||
|
|
||||||
|
// Adjustable extra spacing between columns — tune these to close any
|
||||||
|
// residual sub-filler drift between primary row and secondary stats line.
|
||||||
|
const KD_GAP = 4;
|
||||||
|
const TGS_GAP = 0;
|
||||||
|
const HEAL_GAP = TGS_GAP + 5; // separate from TGS_GAP since heal lacks the [ ] brackets tgCount has
|
||||||
|
|
||||||
|
function formatRankOnly(currentRank: number, goalMet: boolean): string {
|
||||||
|
if (!currentRank || currentRank === 0) return "—";
|
||||||
|
const suffix = goalMet ? "_gold" : "";
|
||||||
|
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRow(
|
||||||
|
row: LeaderboardRow,
|
||||||
|
context: NationContext,
|
||||||
|
allNames: string[],
|
||||||
|
allScores: string[],
|
||||||
|
allKds: string[],
|
||||||
|
allTgs: string[],
|
||||||
|
allAtks: string[],
|
||||||
|
allDefs: string[],
|
||||||
|
allHeals: string[]
|
||||||
|
): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const goalMet = row.tgCount >= goal;
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
|
||||||
|
const scoreText = format.scoreBold(row.weeklyPts);
|
||||||
|
const kdText = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "—";
|
||||||
|
const tgText = `[${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
|
||||||
|
// Each column's target width = widest of (primary stat, secondary stat
|
||||||
|
// that will appear below it) — so the primary row makes room for the
|
||||||
|
// secondary stats line beneath it, keeping both column-locked together.
|
||||||
|
const scoreColumn = [...allScores, ...allAtks];
|
||||||
|
const kdColumn = [...allKds, ...allDefs];
|
||||||
|
const tgsColumn = [...allTgs, ...allHeals];
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: TextAlign.padToMax(char.name, allNames),
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
score: `${scoreEmoji} ${TextAlign.gap(4)}${TextAlign.padToMax(scoreText, scoreColumn)}`,
|
||||||
|
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, kdColumn),
|
||||||
|
tgs: TextAlign.gap(TGS_GAP) + TextAlign.padLeftToMax(tgText, tgsColumn),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
|
||||||
|
if (!row.stats || (!row.stats.atk && !row.stats.def && !row.stats.heal)) return mainLine;
|
||||||
|
|
||||||
|
const prefixText = `${tokens.rank} ${tokens.class} ${tokens.name}${tokens.indicators}`;
|
||||||
|
const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText));
|
||||||
|
const atkText = format.statText("anima_atk", row.stats.atk, "⚔️");
|
||||||
|
const defText = format.statText("anima_def", row.stats.def, "🛡️");
|
||||||
|
const healText = format.statText("anima_massheal", row.stats.heal, "💚");
|
||||||
|
|
||||||
|
const statsLine = `${prefixGap} ${TextAlign.gap(4)}${TextAlign.padToMax(atkText, scoreColumn)} ${TextAlign.gap(KD_GAP)}${TextAlign.padToMax(defText, kdColumn)} ${TextAlign.gap(HEAL_GAP)}${TextAlign.padLeftToMax(healText, tgsColumn)}`;
|
||||||
|
|
||||||
|
return `${mainLine}\n${statsLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const sortedCapella = [...capellaRows].sort(sortByPts);
|
||||||
|
const sortedProcyon = [...procyonRows].sort(sortByPts);
|
||||||
|
|
||||||
|
const capellaNames = sortedCapella.map((r) => r.character.name);
|
||||||
|
const procyonNames = sortedProcyon.map((r) => r.character.name);
|
||||||
|
const capellaScores = sortedCapella.map((r) => format.scoreBold(r.weeklyPts));
|
||||||
|
const procyonScores = sortedProcyon.map((r) => format.scoreBold(r.weeklyPts));
|
||||||
|
const capellaKds = sortedCapella.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
|
||||||
|
const procyonKds = sortedProcyon.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const capellaTgs = sortedCapella.map((r) => `[${Layout.tgCount(r.tgCount, goal)}]`);
|
||||||
|
const procyonTgs = sortedProcyon.map((r) => `[${Layout.tgCount(r.tgCount, goal)}]`);
|
||||||
|
|
||||||
|
const atkEmoji = Emoji.get("atk") || "⚔️";
|
||||||
|
const defEmoji = Emoji.get("def") || "🛡️";
|
||||||
|
const healEmoji = Emoji.get("heal") || "💚";
|
||||||
|
const capellaAtks = sortedCapella.map((r) => r.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.stats.atk)}` : "");
|
||||||
|
const procyonAtks = sortedProcyon.map((r) => r.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.stats.atk)}` : "");
|
||||||
|
const capellaDefs = sortedCapella.map((r) => r.stats?.def ? `${defEmoji} ${format.number.abbrev(r.stats.def)}` : "");
|
||||||
|
const procyonDefs = sortedProcyon.map((r) => r.stats?.def ? `${defEmoji} ${format.number.abbrev(r.stats.def)}` : "");
|
||||||
|
const capellaHeals = sortedCapella.map((r) => r.stats?.heal ? `${healEmoji} ${format.number.abbrev(r.stats.heal)}` : "");
|
||||||
|
const procyonHeals = sortedProcyon.map((r) => r.stats?.heal ? `${healEmoji} ${format.number.abbrev(r.stats.heal)}` : "");
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const capellaFormatted = sortedCapella.map((r) => formatRow(r, capContext, capellaNames, capellaScores, capellaKds, capellaTgs, capellaAtks, capellaDefs, capellaHeals));
|
||||||
|
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNames, procyonScores, procyonKds, procyonTgs, procyonAtks, procyonDefs, procyonHeals));
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerColumn(embed, `${capellaEmoji} Capella`, capellaFormatted);
|
||||||
|
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
|
||||||
|
EmbedHelpers.addPerPlayerColumn(embed, `${procyonEmoji} Procyon`, procyonFormatted);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequentialExtraStatsLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "sequential-extra-stats",
|
||||||
|
description: "Same as sequential, plus weekly atk/def/heal on indented second line",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow: formatRow as any,
|
||||||
|
};
|
||||||
79
src/ui/leaderboard/layouts/horizontal-sequential-stacked.ts
Normal file
79
src/ui/leaderboard/layouts/horizontal-sequential-stacked.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* Sequential-stacked leaderboard layout — Capella's full list then Procyon's,
|
||||||
|
* each player using 3 stacked lines (name / score+tg / kd), no delta.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{rank} {class} {name}{indicators}";
|
||||||
|
|
||||||
|
function formatRankOnly(currentRank: number, goalMet: boolean): string {
|
||||||
|
if (!currentRank || currentRank === 0) return "—";
|
||||||
|
const suffix = goalMet ? "_gold" : "";
|
||||||
|
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const goalMet = row.tgCount >= goal;
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)} · [${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
|
||||||
|
const lines = [mainLine, `\u3000${scoreLine}`];
|
||||||
|
if (kdLine) lines.push(`\u3000${kdLine}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
EmbedHelpers.addNationFields(embed, `${capellaEmoji} Capella`, capellaFormatted, false);
|
||||||
|
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
|
||||||
|
EmbedHelpers.addNationFields(embed, `${procyonEmoji} Procyon`, procyonFormatted, false);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequentialStackedLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "sequential-stacked",
|
||||||
|
description: "Capella then Procyon, 3-line stacked rows per player, no delta",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
105
src/ui/leaderboard/layouts/horizontal-sequential.ts
Normal file
105
src/ui/leaderboard/layouts/horizontal-sequential.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Sequential leaderboard layout — Capella's full ranked list first,
|
||||||
|
* then Procyon's full ranked list below. No delta, rank only.
|
||||||
|
* Names AND scores padded with invisible filler characters for
|
||||||
|
* approximate column alignment, computed PER NATION.
|
||||||
|
*
|
||||||
|
* NOTE: "—" and "·" separators kept for now as visual markers while
|
||||||
|
* tuning alignment — once padding is solid, these can likely be
|
||||||
|
* removed since padding alone will provide visual column separation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { TextAlign } from "@ui/text-align";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{rank} {class} {name}{indicators} {score} {kd} {tgs}";
|
||||||
|
|
||||||
|
function formatRankOnly(currentRank: number, goalMet: boolean): string {
|
||||||
|
if (!currentRank || currentRank === 0) return "—";
|
||||||
|
const suffix = goalMet ? "_gold" : "";
|
||||||
|
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRow(
|
||||||
|
row: LeaderboardRow,
|
||||||
|
context: NationContext,
|
||||||
|
allNames: string[],
|
||||||
|
allScores: string[],
|
||||||
|
allKds: string[],
|
||||||
|
allTgs: string[]
|
||||||
|
): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const goalMet = row.tgCount >= goal;
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
|
||||||
|
const scoreText = format.scoreBold(row.weeklyPts);
|
||||||
|
const kdText = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "—";
|
||||||
|
const tgText = `[${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: TextAlign.padToMax(char.name, allNames),
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
score: `${scoreEmoji} ${TextAlign.padToMax(scoreText, allScores)}`,
|
||||||
|
kd: TextAlign.gap(7) + TextAlign.padToMax(kdText, allKds),
|
||||||
|
tgs: TextAlign.gap(7) + TextAlign.padLeftToMax(tgText, allTgs),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const sortedCapella = [...capellaRows].sort(sortByPts);
|
||||||
|
const sortedProcyon = [...procyonRows].sort(sortByPts);
|
||||||
|
|
||||||
|
const capellaNames = sortedCapella.map((r) => r.character.name);
|
||||||
|
const procyonNames = sortedProcyon.map((r) => r.character.name);
|
||||||
|
const capellaScores = sortedCapella.map((r) => format.scoreBold(r.weeklyPts));
|
||||||
|
const procyonScores = sortedProcyon.map((r) => format.scoreBold(r.weeklyPts));
|
||||||
|
const capellaKds = sortedCapella.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
|
||||||
|
const procyonKds = sortedProcyon.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
|
||||||
|
const capellaTgs = sortedCapella.map((r) => `[${Layout.tgCount(r.tgCount, Config.get({ section: "wrank", key: "goal" }))}]`);
|
||||||
|
const procyonTgs = sortedProcyon.map((r) => `[${Layout.tgCount(r.tgCount, Config.get({ section: "wrank", key: "goal" }))}]`);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const capellaFormatted = sortedCapella.map((r) => formatRow(r, capContext, capellaNames, capellaScores, capellaKds, capellaTgs));
|
||||||
|
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNames, procyonScores, procyonKds, procyonTgs));
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerColumn(embed, `${capellaEmoji} Capella`, capellaFormatted);
|
||||||
|
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
|
||||||
|
EmbedHelpers.addPerPlayerColumn(embed, `${procyonEmoji} Procyon`, procyonFormatted);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequentialLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "sequential",
|
||||||
|
description: "Capella then Procyon, rank only, names+scores+kd padded per-nation",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow: formatRow as any,
|
||||||
|
};
|
||||||
116
src/ui/leaderboard/layouts/side-by-side-sequential.ts
Normal file
116
src/ui/leaderboard/layouts/side-by-side-sequential.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Side-by-side-sequential leaderboard layout — nations rendered side by
|
||||||
|
* side in a 2-column grid. Due to Discord's narrower column width in
|
||||||
|
* this layout, content wraps to 2 lines per player:
|
||||||
|
* Line 1: rank + class + name + indicators + TG count
|
||||||
|
* Line 2: score + K/D (indented)
|
||||||
|
* Uses the same TextAlign column-alignment technique as "sequential".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { TextAlign } from "@ui/text-align";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const LINE1_TEMPLATE = "{rank} {class} {name}{indicators} {tgs}";
|
||||||
|
const LINE2_TEMPLATE = "{score} {kd}";
|
||||||
|
|
||||||
|
// Adjust to tune spacing between columns within each line.
|
||||||
|
const TGS_GAP = 5;
|
||||||
|
const KD_GAP = 3;
|
||||||
|
|
||||||
|
function formatRankOnly(currentRank: number, goalMet: boolean): string {
|
||||||
|
if (!currentRank || currentRank === 0) return "—";
|
||||||
|
const suffix = goalMet ? "_gold" : "";
|
||||||
|
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRow(
|
||||||
|
row: LeaderboardRow,
|
||||||
|
context: NationContext,
|
||||||
|
allNames: string[],
|
||||||
|
allScores: string[],
|
||||||
|
allKds: string[],
|
||||||
|
allTgs: string[]
|
||||||
|
): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const goalMet = row.tgCount >= goal;
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
|
||||||
|
const scoreText = format.scoreBold(row.weeklyPts);
|
||||||
|
const kdText = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "—";
|
||||||
|
const tgText = `[${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
|
||||||
|
const line1Tokens: Record<string, string> = {
|
||||||
|
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
tgs: TextAlign.gap(TGS_GAP) + TextAlign.padLeftToMax(tgText, allTgs),
|
||||||
|
};
|
||||||
|
|
||||||
|
const line2Tokens: Record<string, string> = {
|
||||||
|
score: `${scoreEmoji} ${TextAlign.padToMax(scoreText, allScores)}`,
|
||||||
|
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, allKds),
|
||||||
|
};
|
||||||
|
|
||||||
|
const line1 = Layout.formatRow(LINE1_TEMPLATE, line1Tokens);
|
||||||
|
const line2 = Layout.formatRow(LINE2_TEMPLATE, line2Tokens);
|
||||||
|
|
||||||
|
return `${line1}\n\u3000${line2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const sortedCapella = [...capellaRows].sort(sortByPts);
|
||||||
|
const sortedProcyon = [...procyonRows].sort(sortByPts);
|
||||||
|
|
||||||
|
const capellaNames = sortedCapella.map((r) => r.character.name);
|
||||||
|
const procyonNames = sortedProcyon.map((r) => r.character.name);
|
||||||
|
const capellaScores = sortedCapella.map((r) => format.scoreBold(r.weeklyPts));
|
||||||
|
const procyonScores = sortedProcyon.map((r) => format.scoreBold(r.weeklyPts));
|
||||||
|
const capellaKds = sortedCapella.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
|
||||||
|
const procyonKds = sortedProcyon.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const capellaTgs = sortedCapella.map((r) => `[${Layout.tgCount(r.tgCount, goal)}]`);
|
||||||
|
const procyonTgs = sortedProcyon.map((r) => `[${Layout.tgCount(r.tgCount, goal)}]`);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const capellaFormatted = sortedCapella.map((r) => formatRow(r, capContext, capellaNames, capellaScores, capellaKds, capellaTgs));
|
||||||
|
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNames, procyonScores, procyonKds, procyonTgs));
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sideBySideSequentialLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "side-by-side-sequential",
|
||||||
|
description: "Nations side by side — name+TG count on line 1, score+kd on line 2",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow: formatRow as any,
|
||||||
|
};
|
||||||
87
src/ui/leaderboard/layouts/side-by-side-stacked.ts
Normal file
87
src/ui/leaderboard/layouts/side-by-side-stacked.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Side-by-side-stacked leaderboard layout — same grid as side-by-side,
|
||||||
|
* but K/D moves to its own line below score for a cleaner per-row look.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {name}{indicators}";
|
||||||
|
const STATS_TEMPLATE = "{score}";
|
||||||
|
const KD_TEMPLATE = "{kd}";
|
||||||
|
const TG_TEMPLATE = "[{tgs}]";
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`;
|
||||||
|
|
||||||
|
const kdLine = (row.totalKills || row.totalDeaths)
|
||||||
|
? format.kd(row.totalKills, row.totalDeaths)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const tgLine = `[${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
|
||||||
|
|
||||||
|
// Three separate lines: name, score, kd+tg
|
||||||
|
const lines = [mainLine, `\u3000${scoreLine}`];
|
||||||
|
if (kdLine) lines.push(`\u3000${kdLine} · ${tgLine}`);
|
||||||
|
else lines[1] += ` · ${tgLine}`; // no K/D — keep score+tg together
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sideBySideStackedLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "side-by-side-stacked",
|
||||||
|
description: "Compact grid with K/D on its own line below score",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
91
src/ui/leaderboard/layouts/side-by-side.ts
Normal file
91
src/ui/leaderboard/layouts/side-by-side.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* Side-by-side leaderboard layout — nations inline (Option C).
|
||||||
|
* Compact — char name + pts + TG count only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {name}{indicators}";
|
||||||
|
const STATS_TEMPLATE = "{score}{kd} · [{tgs}]";
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
const statsTokens: Record<string, string> = {
|
||||||
|
score: `${Emoji.get("score") || "📊"} ${format.scoreBold(row.weeklyPts)}`,
|
||||||
|
kd: Layout.kd(row.totalKills, row.totalDeaths),
|
||||||
|
tgs: Layout.tgCount(row.tgCount, goal),
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${Layout.formatRow(TEMPLATE, mainTokens)}\n\u3000${Layout.formatRow(STATS_TEMPLATE, statsTokens)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const maxPlayers = Math.max(capellaRows.length, procyonRows.length);
|
||||||
|
const useInline = maxPlayers <= 5;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
// .setTitle(`⚔️ W.Rank — ${week.weekKey} · Goal: ${goal} TGs`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
// .addFields(
|
||||||
|
// {
|
||||||
|
// name: `${capellaEmoji} Capella`,
|
||||||
|
// value: [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext)).join("\n\n") || "—",
|
||||||
|
// inline: useInline,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: `${procyonEmoji} Procyon`,
|
||||||
|
// value: [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext)).join("\n\n") || "—",
|
||||||
|
// inline: useInline,
|
||||||
|
// },
|
||||||
|
// )
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sideBySideLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "side-by-side",
|
||||||
|
description: "Nations inline, compact — char name, pts, TG count",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
75
src/ui/leaderboard/layouts/stacked-tg-bottom.ts
Normal file
75
src/ui/leaderboard/layouts/stacked-tg-bottom.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* Stacked leaderboard layout — Option 2: TG count gets its own line at the bottom.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {name}{indicators}";
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`;
|
||||||
|
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
|
||||||
|
const tgLine = `[${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
|
||||||
|
const lines = [mainLine, `\u3000${scoreLine}`];
|
||||||
|
if (kdLine) lines.push(`\u3000${kdLine}`);
|
||||||
|
lines.push(`\u3000${tgLine}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stackedTgBottomLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "stacked-tg-bottom",
|
||||||
|
description: "TG count on its own line at the bottom",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
73
src/ui/leaderboard/layouts/stacked-tg-score.ts
Normal file
73
src/ui/leaderboard/layouts/stacked-tg-score.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* Stacked leaderboard layout — Option 3: TG count joins the score line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {name}{indicators}";
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)} · [${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
|
||||||
|
const lines = [mainLine, `\u3000${scoreLine}`];
|
||||||
|
if (kdLine) lines.push(`\u3000${kdLine}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stackedTgWithScoreLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "stacked-tg-with-score",
|
||||||
|
description: "TG count joins the score line, K/D below",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
74
src/ui/leaderboard/layouts/stacked-tg-top.ts
Normal file
74
src/ui/leaderboard/layouts/stacked-tg-top.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* Stacked leaderboard layout — Option 1: TG count joins the name line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {name}{indicators} · [{tgs}]";
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
tgs: Layout.tgCount(row.tgCount, goal),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`;
|
||||||
|
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
|
||||||
|
const lines = [mainLine, `\u3000${scoreLine}`];
|
||||||
|
if (kdLine) lines.push(`\u3000${kdLine}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stackedTgTopLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "stacked-tg-top",
|
||||||
|
description: "TG count joins the name line, score and K/D each on their own line",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
85
src/ui/leaderboard/layouts/stacked-with-rank.ts
Normal file
85
src/ui/leaderboard/layouts/stacked-with-rank.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Stacked-with-rank leaderboard layout — same as stacked-tg-with-score,
|
||||||
|
* but shows W.Rank position (no delta) prefixed on the name line.
|
||||||
|
* Rank number turns gold when goal TGs are met, same coloring as TG count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { WRankWeek } from "@systems/wrank";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { LeaderboardLayout, LeaderboardRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{rank} {class} {name}{indicators}";
|
||||||
|
|
||||||
|
function formatRankOnly(currentRank: number, goalMet: boolean): string {
|
||||||
|
if (currentRank === 0) return "";
|
||||||
|
const suffix = goalMet ? "_gold" : "";
|
||||||
|
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRow(row: LeaderboardRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const goalMet = row.tgCount >= goal;
|
||||||
|
const rank = row.position && row.position.currentRank !== 0
|
||||||
|
? formatRankOnly(row.position.currentRank, goalMet)
|
||||||
|
: (context.nationHasRank ? (Emoji.get("wrank_no_dash") || "—") : "");
|
||||||
|
|
||||||
|
const mainTokens: Record<string, string> = {
|
||||||
|
rank: rank,
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)} · [${Layout.tgCount(row.tgCount, goal)}]`;
|
||||||
|
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
|
||||||
|
const lines = [mainLine, `\u3000${scoreLine}`];
|
||||||
|
if (kdLine) lines.push(`\u3000${kdLine}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
|
||||||
|
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerGrid(embed, [
|
||||||
|
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
|
||||||
|
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stackedWithRankLeaderboardLayout: LeaderboardLayout = {
|
||||||
|
name: "stacked-with-rank",
|
||||||
|
description: "Same as stacked-tg-with-score but shows W.Rank position (no delta)",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
|
|
@ -17,19 +17,9 @@
|
||||||
|
|
||||||
// ─── W.Rank formatting ────────────────────────────────────────────────────────
|
// ─── W.Rank formatting ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function formatWRank(
|
export function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
|
||||||
wRankEntry: WRankEntry | null,
|
const tgGoal = Config.get({ section: "wrank", key: "goal" });
|
||||||
context: PollRowContext
|
return format.wrank.row(wRankEntry, tgGoal, context);
|
||||||
): string {
|
|
||||||
if (!wRankEntry || wRankEntry.currentRank === 0) {
|
|
||||||
if (!context.nationHasRank) return "";
|
|
||||||
return format.wrank.noRank({ delta: context.nationHasDelta });
|
|
||||||
}
|
|
||||||
|
|
||||||
const goal = Config.get({ section: "wrank", key: "goal" });
|
|
||||||
const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta;
|
|
||||||
return format.wrank.rank(wRankEntry, goal) +
|
|
||||||
format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Row formatting ───────────────────────────────────────────────────────────
|
// ─── Row formatting ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -141,12 +131,14 @@
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveColor(state: PollState): number {
|
export function resolveColor(state: PollState): number {
|
||||||
if (state.confirmed === "yes") return 0x57f287;
|
if (state.confirmed === "yes") return 0x57f287; // green
|
||||||
if (state.confirmed === "no") return 0xed4245;
|
if (state.confirmed === "no") return 0xed4245; // red
|
||||||
if (state.locked) return 0x888888;
|
if ((state as any).called) return 0xe8a317; // orange
|
||||||
return 0xe8a317;
|
if (state.locked) return 0x888888; // grey
|
||||||
}
|
return 0xe8a317; // orange (open)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function resolveTitle(state: PollState, yesByNation: Record<Nation, VoteEntry[]>): string {
|
export function resolveTitle(state: PollState, yesByNation: Record<Nation, VoteEntry[]>): string {
|
||||||
const capellaEmoji = Emoji.get("capella");
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
|
@ -157,13 +149,16 @@
|
||||||
const statusSuffix =
|
const statusSuffix =
|
||||||
state.locked ? " 🔒" :
|
state.locked ? " 🔒" :
|
||||||
state.confirmed === "yes" ? " ✅" :
|
state.confirmed === "yes" ? " ✅" :
|
||||||
state.confirmed === "no" ? " ❌" : "";
|
state.confirmed === "no" ? " ❌" :
|
||||||
|
(state as any).called ? " 🔔" : "";
|
||||||
|
|
||||||
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
|
export function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
|
||||||
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
|
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
|
||||||
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
|
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
|
||||||
|
if ((state as any).called) return Config.get({ section: "poll", key: "calledMessage" }) ?? "🔔 TG was called.";
|
||||||
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
|
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
|
||||||
return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +215,14 @@
|
||||||
.setColor(resolveColor(state))
|
.setColor(resolveColor(state))
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
const imageUrl = (state as any).called
|
||||||
|
? Config.get({ section: "poll", key: "calledGameImageUrl" })
|
||||||
|
: state.confirmed === "no"
|
||||||
|
? Config.get({ section: "poll", key: "cancelledImageUrl" })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (imageUrl) embed.setImage(imageUrl);
|
||||||
|
|
||||||
if (useInline) {
|
if (useInline) {
|
||||||
// Side-by-side — no spacer needed
|
// Side-by-side — no spacer needed
|
||||||
embed.addFields(
|
embed.addFields(
|
||||||
|
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
/**
|
|
||||||
* Default poll layout — vertical, nation-separated fields.
|
|
||||||
* This is the standard layout and always the fallback.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EmbedBuilder } from "discord.js";
|
|
||||||
import { PollState, VoteEntry, Nation } from "@types";
|
|
||||||
import { WRankEntry } from "@systems/wrank";
|
|
||||||
import { Config } from "@systems/config";
|
|
||||||
import { WRank } from "@systems/wrank";
|
|
||||||
import { Bringer } from "@systems/bringer";
|
|
||||||
import { Emoji } from "@systems/emojis";
|
|
||||||
import { format } from "@format";
|
|
||||||
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
|
||||||
import { Leaves } from "@systems/leaves";
|
|
||||||
|
|
||||||
// ─── Row formatting ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
|
|
||||||
if (!wRankEntry || wRankEntry.currentRank === 0) {
|
|
||||||
if (!context.nationHasRank) return "";
|
|
||||||
return format.wrank.noRank({ delta: context.nationHasDelta });
|
|
||||||
}
|
|
||||||
|
|
||||||
const goal = Config.get({ section: "wrank", key: "goal" });
|
|
||||||
const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta;
|
|
||||||
return format.wrank.rank(wRankEntry, goal) +
|
|
||||||
format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRow(entry: VoteEntry, context: PollRowContext): string {
|
|
||||||
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
|
|
||||||
const nation = entry.characterNation;
|
|
||||||
|
|
||||||
const wRankEntry = entry.characterName && entry.characterNation
|
|
||||||
? WRank.entry(entry.characterName, entry.characterNation)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const wrank = formatWRank(wRankEntry, context);
|
|
||||||
const classStr = entry.characterClass
|
|
||||||
? (Emoji.class(entry.characterClass) || entry.characterClass)
|
|
||||||
: "";
|
|
||||||
const levelStr = entry.characterLevel ? `${entry.characterLevel}` : "";
|
|
||||||
|
|
||||||
let row = cfgFormat
|
|
||||||
.replace("{wrank}", wrank)
|
|
||||||
.replace("{class}", classStr)
|
|
||||||
.replace("{level}", levelStr)
|
|
||||||
.replace("{name}", entry.characterName ?? entry.displayName ?? "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (nation && entry.userKey) {
|
|
||||||
const bringer = Bringer.get({ nation });
|
|
||||||
if (bringer && bringer === entry.characterName) {
|
|
||||||
row += ` · ${format.bringer(nation)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.userKey && context.historyKey) {
|
|
||||||
if (Leaves.hasLeft({ characterName: entry.characterName!, historyKey: context.historyKey })) {
|
|
||||||
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName! })}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
|
|
||||||
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContext(
|
|
||||||
entries: VoteEntry[],
|
|
||||||
nation: Nation,
|
|
||||||
options?: { showNationEmoji?: boolean; historyKey?: string }
|
|
||||||
): PollRowContext {
|
|
||||||
const nationHasRank = entries.some((e) =>
|
|
||||||
e.characterName && WRank.entry(e.characterName, nation) !== null
|
|
||||||
);
|
|
||||||
const nationHasDelta = entries.some((e) => {
|
|
||||||
const wr = e.characterName ? WRank.entry(e.characterName, nation) : null;
|
|
||||||
return wr?.previousRank !== undefined;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
nationHasRank,
|
|
||||||
nationHasDelta,
|
|
||||||
showNationEmoji: options?.showNationEmoji ?? false,
|
|
||||||
historyKey: options?.historyKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Embed building ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatNationField(
|
|
||||||
nation: Nation,
|
|
||||||
yesEntries: VoteEntry[],
|
|
||||||
noVoters: VoteEntry[],
|
|
||||||
showNoInline: boolean
|
|
||||||
): string {
|
|
||||||
const context = buildContext(yesEntries, nation);
|
|
||||||
const noEntries = showNoInline
|
|
||||||
? noVoters.filter((e) => e.characterNation === nation)
|
|
||||||
: [];
|
|
||||||
const lines = [
|
|
||||||
...yesEntries.map((e) => formatRow(e, context)),
|
|
||||||
...noEntries.map((e) => `❌ ${formatRow(e, context)}`),
|
|
||||||
];
|
|
||||||
return lines.length > 0 ? lines.join("\n") : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessages(
|
|
||||||
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
|
|
||||||
): string {
|
|
||||||
if (allMessages.length === 0) return "";
|
|
||||||
return allMessages
|
|
||||||
.map((m) => {
|
|
||||||
const name = m.entry.characterName ?? m.entry.displayName;
|
|
||||||
const prefix = m.voteType === "no" ? "✗ " : "✓ ";
|
|
||||||
const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : "";
|
|
||||||
return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveColor(state: PollState): number {
|
|
||||||
if (state.confirmed === "yes") return 0x57f287;
|
|
||||||
if (state.confirmed === "no") return 0xed4245;
|
|
||||||
if (state.locked) return 0x888888;
|
|
||||||
return 0xe8a317;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTitle(
|
|
||||||
state: PollState,
|
|
||||||
yesByNation: Record<Nation, VoteEntry[]>
|
|
||||||
): string {
|
|
||||||
const capellaEmoji = Emoji.get("capella");
|
|
||||||
const procyonEmoji = Emoji.get("procyon");
|
|
||||||
const counts = !state.locked && state.confirmed === null
|
|
||||||
? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}`
|
|
||||||
: "";
|
|
||||||
const statusSuffix =
|
|
||||||
state.locked ? " 🔒" :
|
|
||||||
state.confirmed === "yes" ? " ✅" :
|
|
||||||
state.confirmed === "no" ? " ❌" : "";
|
|
||||||
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
|
|
||||||
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
|
|
||||||
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
|
|
||||||
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
|
|
||||||
return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
|
||||||
const yesByNation: Record<Nation, VoteEntry[]> = {
|
|
||||||
[Nation.Capella]: [],
|
|
||||||
[Nation.Procyon]: [],
|
|
||||||
};
|
|
||||||
const noVoters: VoteEntry[] = [];
|
|
||||||
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
|
|
||||||
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
|
|
||||||
|
|
||||||
for (const entry of state.yes.values()) {
|
|
||||||
const nation = entry.characterNation ?? Nation.Capella;
|
|
||||||
yesByNation[nation].push(entry);
|
|
||||||
allMessages.push({ entry, voteType: "yes" });
|
|
||||||
}
|
|
||||||
for (const entry of state.no.values()) {
|
|
||||||
noVoters.push(entry);
|
|
||||||
allMessages.push({ entry, voteType: "no" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const capellaEmoji = Emoji.get("capella");
|
|
||||||
const procyonEmoji = Emoji.get("procyon");
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(resolveTitle(state, yesByNation))
|
|
||||||
.setColor(resolveColor(state))
|
|
||||||
.addFields(
|
|
||||||
{
|
|
||||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
|
||||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline),
|
|
||||||
inline: false,
|
|
||||||
},
|
|
||||||
{ name: "\u200b", value: "\u200b", inline: false },
|
|
||||||
{
|
|
||||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
|
||||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline),
|
|
||||||
inline: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
const msgSection = formatMessages(allMessages);
|
|
||||||
if (msgSection) {
|
|
||||||
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Layout export ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const defaultLayout: PollLayout = {
|
|
||||||
name: "default",
|
|
||||||
description: "Standard vertical layout with nation-separated fields",
|
|
||||||
buildEmbed,
|
|
||||||
formatRow,
|
|
||||||
buildContext,
|
|
||||||
};
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
/**
|
|
||||||
* Side-by-side poll layout — Capella and Procyon displayed as inline fields.
|
|
||||||
* Nations appear next to each other rather than stacked vertically.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EmbedBuilder } from "discord.js";
|
|
||||||
import { PollState, VoteEntry, Nation } from "@types";
|
|
||||||
import { WRankEntry } from "@systems/wrank";
|
|
||||||
import { Config } from "@systems/config";
|
|
||||||
import { WRank } from "@systems/wrank";
|
|
||||||
import { Bringer } from "@systems/bringer";
|
|
||||||
import { Emoji } from "@systems/emojis";
|
|
||||||
import { format } from "@format";
|
|
||||||
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
|
||||||
import { Leaves } from "@systems/leaves";
|
|
||||||
|
|
||||||
// ─── Row formatting (same as default) ────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
|
|
||||||
if (!wRankEntry || wRankEntry.currentRank === 0) {
|
|
||||||
if (!context.nationHasRank) return "";
|
|
||||||
return format.wrank.noRank({ delta: context.nationHasDelta });
|
|
||||||
}
|
|
||||||
|
|
||||||
const goal = Config.get({ section: "wrank", key: "goal" });
|
|
||||||
const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta;
|
|
||||||
return format.wrank.rank(wRankEntry, goal) +
|
|
||||||
format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRow(entry: VoteEntry, context: PollRowContext): string {
|
|
||||||
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
|
|
||||||
const nation = entry.characterNation;
|
|
||||||
const wRankEntry = entry.characterName && entry.characterNation
|
|
||||||
? WRank.entry(entry.characterName, entry.characterNation)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const wrank = formatWRank(wRankEntry, context);
|
|
||||||
const classStr = entry.characterClass ? (Emoji.class(entry.characterClass) || entry.characterClass) : "";
|
|
||||||
const levelStr = entry.characterLevel ? `${entry.characterLevel}` : "";
|
|
||||||
|
|
||||||
let row = cfgFormat
|
|
||||||
.replace("{wrank}", wrank)
|
|
||||||
.replace("{class}", classStr)
|
|
||||||
.replace("{level}", levelStr)
|
|
||||||
.replace("{name}", entry.characterName ?? entry.displayName ?? "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (nation && entry.userKey) {
|
|
||||||
const bringer = Bringer.get({ nation });
|
|
||||||
if (bringer && bringer === entry.characterName) {
|
|
||||||
row += ` · ${format.bringer(nation)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.userKey && entry.characterName && context.historyKey) {
|
|
||||||
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
|
|
||||||
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
|
|
||||||
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContext(
|
|
||||||
entries: VoteEntry[],
|
|
||||||
nation: Nation,
|
|
||||||
options?: { showNationEmoji?: boolean; historyKey?: string }
|
|
||||||
): PollRowContext {
|
|
||||||
const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null);
|
|
||||||
const nationHasDelta = entries.some((e) => {
|
|
||||||
const wr = e.characterName ? WRank.entry(e.characterName, nation) : null;
|
|
||||||
return wr?.previousRank !== undefined;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
nationHasRank,
|
|
||||||
nationHasDelta,
|
|
||||||
showNationEmoji: options?.showNationEmoji ?? false,
|
|
||||||
historyKey: options?.historyKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Embed building ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatNationField(
|
|
||||||
nation: Nation,
|
|
||||||
yesEntries: VoteEntry[],
|
|
||||||
noVoters: VoteEntry[],
|
|
||||||
showNoInline: boolean,
|
|
||||||
historyKey?: string
|
|
||||||
): string {
|
|
||||||
const context = buildContext(yesEntries, nation, { historyKey });
|
|
||||||
const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : [];
|
|
||||||
const lines = [
|
|
||||||
...yesEntries.map((e) => formatRow(e, context)),
|
|
||||||
...noEntries.map((e) => `❌ ${formatRow(e, context)}`),
|
|
||||||
];
|
|
||||||
return lines.length > 0 ? lines.join("\n") : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessages(allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]): string {
|
|
||||||
if (allMessages.length === 0) return "";
|
|
||||||
return allMessages
|
|
||||||
.map((m) => {
|
|
||||||
const name = m.entry.characterName ?? m.entry.displayName;
|
|
||||||
const prefix = m.voteType === "no" ? "✗ " : "✓ ";
|
|
||||||
const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : "";
|
|
||||||
return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
|
||||||
const yesByNation: Record<Nation, VoteEntry[]> = {
|
|
||||||
[Nation.Capella]: [],
|
|
||||||
[Nation.Procyon]: [],
|
|
||||||
};
|
|
||||||
const noVoters: VoteEntry[] = [];
|
|
||||||
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
|
|
||||||
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
|
|
||||||
|
|
||||||
for (const entry of state.yes.values()) {
|
|
||||||
const nation = entry.characterNation ?? Nation.Capella;
|
|
||||||
yesByNation[nation].push(entry);
|
|
||||||
allMessages.push({ entry, voteType: "yes" });
|
|
||||||
}
|
|
||||||
for (const entry of state.no.values()) {
|
|
||||||
noVoters.push(entry);
|
|
||||||
allMessages.push({ entry, voteType: "no" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const capellaEmoji = Emoji.get("capella");
|
|
||||||
const procyonEmoji = Emoji.get("procyon");
|
|
||||||
|
|
||||||
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
|
|
||||||
|
|
||||||
// Title
|
|
||||||
const counts = !state.locked && state.confirmed === null
|
|
||||||
? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}`
|
|
||||||
: "";
|
|
||||||
const statusSuffix =
|
|
||||||
state.locked ? " 🔒" :
|
|
||||||
state.confirmed === "yes" ? " ✅" :
|
|
||||||
state.confirmed === "no" ? " ❌" : "";
|
|
||||||
|
|
||||||
const color =
|
|
||||||
state.confirmed === "yes" ? 0x57f287 :
|
|
||||||
state.confirmed === "no" ? 0xed4245 :
|
|
||||||
state.locked ? 0x888888 :
|
|
||||||
0xe8a317;
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(`⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`)
|
|
||||||
.setColor(color)
|
|
||||||
.addFields(
|
|
||||||
// ← inline: true makes them side by side
|
|
||||||
{
|
|
||||||
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
|
||||||
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
|
||||||
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
const msgSection = formatMessages(allMessages);
|
|
||||||
if (msgSection) {
|
|
||||||
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
let footer: string;
|
|
||||||
if (state.confirmed === "yes") footer = Config.get({ section: "poll", key: "confirmYes" });
|
|
||||||
else if (state.confirmed === "no") footer = Config.get({ section: "poll", key: "confirmNo" });
|
|
||||||
else if (state.locked) footer = options?.overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
|
|
||||||
else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
|
||||||
embed.setFooter({ text: footer });
|
|
||||||
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Layout export ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const sideBySideLayout: PollLayout = {
|
|
||||||
name: "side-by-side",
|
|
||||||
description: "Nations displayed as inline fields side by side",
|
|
||||||
buildEmbed,
|
|
||||||
formatRow,
|
|
||||||
buildContext,
|
|
||||||
};
|
|
||||||
131
src/ui/result/index.ts
Normal file
131
src/ui/result/index.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* ResultUI — dispatcher for result embed layouts.
|
||||||
|
* Auto-discovers layouts from layouts/ directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
import { Runtime } from "@systems/runtime";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const log = Logger.for("result-ui");
|
||||||
|
|
||||||
|
Runtime.phase("restore", () => restoreResultLayout(), { name: "ResultUI.restoreLayout" });
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResultRow {
|
||||||
|
character: {
|
||||||
|
name: string;
|
||||||
|
class: any;
|
||||||
|
level: number;
|
||||||
|
nation: any;
|
||||||
|
ownerKey: string;
|
||||||
|
};
|
||||||
|
score?: {
|
||||||
|
pts: number;
|
||||||
|
k?: number;
|
||||||
|
d?: number;
|
||||||
|
atk?: number;
|
||||||
|
def?: number;
|
||||||
|
heal?: number;
|
||||||
|
wRankAtSubmission?: { rank: number; delta: number };
|
||||||
|
};
|
||||||
|
position?: { currentRank: number; previousRank?: number };
|
||||||
|
leavesCount: number;
|
||||||
|
historyKey: TGKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultLayout {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder;
|
||||||
|
formatRow(row: ResultRow, context: any): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _layouts = new Map<string, ResultLayout>();
|
||||||
|
let _active: ResultLayout | null = null;
|
||||||
|
|
||||||
|
export function registerResultLayout(layout: ResultLayout): void {
|
||||||
|
_layouts.set(layout.name, layout);
|
||||||
|
if (!_active) _active = layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPollLayout(obj: any): obj is ResultLayout {
|
||||||
|
return obj?.name && obj?.description &&
|
||||||
|
typeof obj?.buildEmbed === "function" &&
|
||||||
|
typeof obj?.formatRow === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverResultLayouts(): void {
|
||||||
|
const dir = path.join(__dirname, "layouts");
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const mod = require(path.join(dir, file));
|
||||||
|
for (const exported of Object.values(mod)) {
|
||||||
|
if (isPollLayout(exported)) {
|
||||||
|
registerResultLayout(exported as ResultLayout);
|
||||||
|
log.info(`Registered result layout: ${(exported as ResultLayout).name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log.error(`Failed to load result layout ${file}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoverResultLayouts();
|
||||||
|
|
||||||
|
// ─── Restore saved layout ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function restoreResultLayout(): void {
|
||||||
|
const saved = Config.get({ section: "result", key: "layout" });
|
||||||
|
if (saved && _layouts.has(saved)) {
|
||||||
|
_active = _layouts.get(saved)!;
|
||||||
|
log.info(`Restored result layout: ${saved}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function activeLayout(): ResultLayout {
|
||||||
|
const layout = _active ?? _layouts.values().next().value;
|
||||||
|
if (!layout) throw new Error("[ResultUI] No layouts registered");
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ResultUI = {
|
||||||
|
buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
|
||||||
|
return activeLayout().buildEmbed(historyKey, rows);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatRow(row: ResultRow, context: any): string {
|
||||||
|
return activeLayout().formatRow(row, context);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLayout(name: string): boolean {
|
||||||
|
const layout = _layouts.get(name);
|
||||||
|
if (!layout) return false;
|
||||||
|
_active = layout;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
layouts(): { name: string; description: string }[] {
|
||||||
|
return [..._layouts.values()].map((l) => ({ name: l.name, description: l.description }));
|
||||||
|
},
|
||||||
|
|
||||||
|
register: registerResultLayout,
|
||||||
|
};
|
||||||
94
src/ui/result/layouts/default.ts
Normal file
94
src/ui/result/layouts/default.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Default result layout — stats on new line with ┕ prefix (Option B).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { ResultLayout, ResultRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {level} {name}{indicators} — {score}{kd}";
|
||||||
|
|
||||||
|
function formatRow(row: ResultRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.score?.pts, 1);
|
||||||
|
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
level: `${char.level}`,
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any, { historyKey: row.historyKey }),
|
||||||
|
score: row.score ? `${Emoji.get("score") || "📊"} ${format.scoreBold(row.score.pts)}` : "—",
|
||||||
|
kd: row.score ? Layout.kd(row.score.k ?? 0, row.score.d ?? 0) : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
|
||||||
|
const statsStr = row.score
|
||||||
|
? format.stats(
|
||||||
|
row.score.atk || row.score.def || row.score.heal
|
||||||
|
? { atk: row.score.atk, def: row.score.def, heal: row.score.heal }
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return statsStr ? `${mainLine}\n┕ ${statsStr}` : mainLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const { date, slot } = TGKey.parse(historyKey);
|
||||||
|
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
|
||||||
|
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
const proK = procyonRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const proD = procyonRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const sortByScore = (a: ResultRow, b: ResultRow) =>
|
||||||
|
(b.score?.pts ?? 0) - (a.score?.pts ?? 0);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: `${capellaEmoji} Capella${(capK || capD) ? ` — ${format.kd(capK, capD)}` : ""}`,
|
||||||
|
value: [...capellaRows].sort(sortByScore).map((r) => formatRow(r, capContext)).join("\n") || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: false },
|
||||||
|
{
|
||||||
|
name: `${procyonEmoji} Procyon${(proK || proD) ? ` — ${format.kd(proK, proD)}` : ""}`,
|
||||||
|
value: [...procyonRows].sort(sortByScore).map((r) => formatRow(r, proContext)).join("\n") || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultResultLayout: ResultLayout = {
|
||||||
|
name: "default",
|
||||||
|
description: "Stats on second line with ┕ prefix",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
91
src/ui/result/layouts/inline.ts
Normal file
91
src/ui/result/layouts/inline.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* Inline result layout — stats on same line with em space separation (Option A).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { ResultLayout, ResultRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {level} {name}{indicators} — {score}{kd}{stats}";
|
||||||
|
|
||||||
|
function formatRow(row: ResultRow, context: NationContext): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.score?.pts, 1);
|
||||||
|
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
const statsStr = row.score
|
||||||
|
? format.stats(
|
||||||
|
row.score.atk || row.score.def || row.score.heal
|
||||||
|
? { atk: row.score.atk, def: row.score.def, heal: row.score.heal }
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
level: `${char.level}`,
|
||||||
|
name: char.name,
|
||||||
|
indicators: Layout.indicators(char as any, { historyKey: row.historyKey }),
|
||||||
|
score: row.score ? `${Emoji.get("score") || "📊"} ${format.scoreBold(row.score.pts)}` : "—",
|
||||||
|
kd: row.score ? Layout.kd(row.score.k ?? 0, row.score.d ?? 0) : "",
|
||||||
|
stats: statsStr ? `\u2003\u2003${statsStr}` : "", // em spaces for separation
|
||||||
|
};
|
||||||
|
|
||||||
|
return Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
|
||||||
|
const { date, slot } = TGKey.parse(historyKey);
|
||||||
|
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
|
||||||
|
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
const proK = procyonRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const proD = procyonRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const sortByScore = (a: ResultRow, b: ResultRow) =>
|
||||||
|
(b.score?.pts ?? 0) - (a.score?.pts ?? 0);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: `${capellaEmoji} Capella${(capK || capD) ? ` — ${format.kd(capK, capD)}` : ""}`,
|
||||||
|
value: [...capellaRows].sort(sortByScore).map((r) => formatRow(r, capContext)).join("\n") || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: false },
|
||||||
|
{
|
||||||
|
name: `${procyonEmoji} Procyon${(proK || proD) ? ` — ${format.kd(proK, proD)}` : ""}`,
|
||||||
|
value: [...procyonRows].sort(sortByScore).map((r) => formatRow(r, proContext)).join("\n") || "—",
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inlineResultLayout: ResultLayout = {
|
||||||
|
name: "inline",
|
||||||
|
description: "Stats inline with em space separation",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow,
|
||||||
|
};
|
||||||
134
src/ui/result/layouts/sequential.ts
Normal file
134
src/ui/result/layouts/sequential.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* Sequential result layout — same alignment technique as the
|
||||||
|
* leaderboard's "sequential" layout: name, score, and K/D padded via
|
||||||
|
* TextAlign, computed per-nation. Secondary stats (atk/def/heal) shown
|
||||||
|
* on an indented second line when present.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { Nation, ClassKey } from "@types";
|
||||||
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Config } from "@systems/config";
|
||||||
|
import { format } from "@format";
|
||||||
|
import { Layout, NationContext } from "@ui/layout";
|
||||||
|
import { EmbedHelpers } from "@ui/embed-helpers";
|
||||||
|
import { TextAlign } from "@ui/text-align";
|
||||||
|
import { ResultLayout, ResultRow } from "../index";
|
||||||
|
|
||||||
|
const TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}";
|
||||||
|
|
||||||
|
// Same gap values tuned for the leaderboard's sequential-extra-stats —
|
||||||
|
// keeps score/atk and kd/def column-locked together.
|
||||||
|
const SCORE_GAP = 4;
|
||||||
|
const KD_GAP = 4;
|
||||||
|
const DEF_GAP = 1;
|
||||||
|
const HEAL_GAP = 4;
|
||||||
|
const DEF_WIDTH_OFFSET = -0.1; // negative width units, pulls def slightly left
|
||||||
|
|
||||||
|
function formatRow(
|
||||||
|
row: ResultRow,
|
||||||
|
context: NationContext,
|
||||||
|
allNames: string[],
|
||||||
|
allScores: string[],
|
||||||
|
allKds: string[],
|
||||||
|
allAtks: string[],
|
||||||
|
allDefs: string[]
|
||||||
|
): string {
|
||||||
|
const char = row.character;
|
||||||
|
const goal = Config.get({ section: "wrank", key: "goal" });
|
||||||
|
const wrEntry = Layout.wrankEntry(char as any, row.position, row.score?.pts, 1);
|
||||||
|
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
|
|
||||||
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
const scoreText = row.score ? format.scoreBold(row.score.pts) : "—";
|
||||||
|
const kdText = row.score && (row.score.k || row.score.d) ? format.kd(row.score.k ?? 0, row.score.d ?? 0) : "—";
|
||||||
|
|
||||||
|
// Column targets combine primary + secondary stat widths, so the main
|
||||||
|
// row makes room for the stats line beneath it.
|
||||||
|
const scoreColumn = [...allScores, ...allAtks];
|
||||||
|
const kdColumn = [...allKds, ...allDefs];
|
||||||
|
|
||||||
|
const tokens: Record<string, string> = {
|
||||||
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
class: Emoji.class(classKey) || classKey || "?",
|
||||||
|
name: TextAlign.padToMax(char.name, allNames),
|
||||||
|
indicators: Layout.indicators(char as any, { historyKey: row.historyKey }),
|
||||||
|
score: `${scoreEmoji} ${TextAlign.padToMax(scoreText, scoreColumn)}`,
|
||||||
|
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, kdColumn),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
|
||||||
|
const hasStats = row.score && (row.score.atk || row.score.def || row.score.heal);
|
||||||
|
if (!hasStats) return mainLine;
|
||||||
|
|
||||||
|
const prefixText = `${tokens.wrank} ${tokens.class} ${tokens.name}${tokens.indicators}`;
|
||||||
|
const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText));
|
||||||
|
const atkText = format.statText("anima_atk", row.score!.atk, "⚔️");
|
||||||
|
const defText = format.statText("anima_def", row.score!.def, "🛡️");
|
||||||
|
const healText = format.statText("anima_massheal", row.score!.heal, "💚");
|
||||||
|
|
||||||
|
const statsLine = `${prefixGap} ${TextAlign.gap(SCORE_GAP)}${TextAlign.padToMax(atkText, scoreColumn)} ${TextAlign.gap(DEF_GAP)}${TextAlign.padToMaxOffset(defText, kdColumn, DEF_WIDTH_OFFSET)} ${TextAlign.gap(HEAL_GAP)}${healText}`;
|
||||||
|
|
||||||
|
return `${mainLine}\n${statsLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
|
||||||
|
const { date, slot } = TGKey.parse(historyKey);
|
||||||
|
|
||||||
|
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
|
||||||
|
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
|
||||||
|
|
||||||
|
const capContext = Layout.nationContext(capellaRows);
|
||||||
|
const proContext = Layout.nationContext(procyonRows);
|
||||||
|
|
||||||
|
const sortByScore = (a: ResultRow, b: ResultRow) => (b.score?.pts ?? 0) - (a.score?.pts ?? 0);
|
||||||
|
|
||||||
|
const sortedCapella = [...capellaRows].sort(sortByScore);
|
||||||
|
const sortedProcyon = [...procyonRows].sort(sortByScore);
|
||||||
|
|
||||||
|
const capellaNames = sortedCapella.map((r) => r.character.name);
|
||||||
|
const procyonNames = sortedProcyon.map((r) => r.character.name);
|
||||||
|
const capellaScores = sortedCapella.map((r) => r.score ? format.scoreBold(r.score.pts) : "—");
|
||||||
|
const procyonScores = sortedProcyon.map((r) => r.score ? format.scoreBold(r.score.pts) : "—");
|
||||||
|
const capellaKds = sortedCapella.map((r) => r.score && (r.score.k || r.score.d) ? format.kd(r.score.k ?? 0, r.score.d ?? 0) : "—");
|
||||||
|
const procyonKds = sortedProcyon.map((r) => r.score && (r.score.k || r.score.d) ? format.kd(r.score.k ?? 0, r.score.d ?? 0) : "—");
|
||||||
|
|
||||||
|
const atkEmoji = Emoji.get("atk") || "⚔️";
|
||||||
|
const defEmoji = Emoji.get("def") || "🛡️";
|
||||||
|
const capellaAtks = sortedCapella.map((r) => r.score?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.atk)}` : "");
|
||||||
|
const procyonAtks = sortedProcyon.map((r) => r.score?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.atk)}` : "");
|
||||||
|
const capellaDefs = sortedCapella.map((r) => r.score?.def ? `${defEmoji} ${format.number.abbrev(r.score.def)}` : "");
|
||||||
|
const procyonDefs = sortedProcyon.map((r) => r.score?.def ? `${defEmoji} ${format.number.abbrev(r.score.def)}` : "");
|
||||||
|
|
||||||
|
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
const proK = procyonRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
|
||||||
|
const proD = procyonRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
|
||||||
|
const capellaEmoji = Emoji.get("capella");
|
||||||
|
const procyonEmoji = Emoji.get("procyon");
|
||||||
|
|
||||||
|
const capellaFormatted = sortedCapella.map((r) => formatRow(r, capContext, capellaNames, capellaScores, capellaKds, capellaAtks, capellaDefs));
|
||||||
|
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNames, procyonScores, procyonKds, procyonAtks, procyonDefs));
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
|
||||||
|
.setColor(0xe8a317)
|
||||||
|
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
EmbedHelpers.addPerPlayerColumn(embed, `${capellaEmoji} Capella${(capK || capD) ? ` — ${format.kd(capK, capD)}` : ""}`, capellaFormatted);
|
||||||
|
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
|
||||||
|
EmbedHelpers.addPerPlayerColumn(embed, `${procyonEmoji} Procyon${(proK || proD) ? ` — ${format.kd(proK, proD)}` : ""}`, procyonFormatted);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequentialResultLayout: ResultLayout = {
|
||||||
|
name: "sequential",
|
||||||
|
description: "Name/score/K-D aligned via TextAlign, stats on indented second line",
|
||||||
|
buildEmbed,
|
||||||
|
formatRow: formatRow as any,
|
||||||
|
};
|
||||||
183
src/ui/text-align.ts
Normal file
183
src/ui/text-align.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* TextAlign — approximate column alignment for Discord embeds using
|
||||||
|
* invisible filler characters.
|
||||||
|
*
|
||||||
|
* Discord embed text is NOT monospace. Character widths below are
|
||||||
|
* EXACT values extracted directly from Discord's actual font (gg sans
|
||||||
|
* Regular), via font metrics (advance width / units-per-em), normalized
|
||||||
|
* to 1.0 = full em. This is real font data, not approximation.
|
||||||
|
*
|
||||||
|
* CRITICAL CAVEAT: the invisible filler character used for padding
|
||||||
|
* (Thin Space U+2009, Hangul Filler U+3164, etc.) does NOT exist in
|
||||||
|
* gg sans itself — confirmed by checking the font's cmap. Discord's
|
||||||
|
* renderer falls back to some OTHER font (OS/system fallback) to draw
|
||||||
|
* these glyphs, which we have no programmatic access to measure. This
|
||||||
|
* means FILLER_WIDTH below MUST remain empirically calibrated via live
|
||||||
|
* Discord testing — it cannot be derived from the gg sans font file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { TextAlign } from "@ui/text-align";
|
||||||
|
*
|
||||||
|
* const width = TextAlign.estimateWidth("»Flash«");
|
||||||
|
* const padded = TextAlign.pad("»Flash«", maxWidth);
|
||||||
|
* const maxWidth = TextAlign.maxWidth(["»Flash«", "XefronYokuda", ...]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
|
||||||
|
const log = Logger.for("TextAlign");
|
||||||
|
|
||||||
|
const FILLER = "\u2009"; // Thin Space
|
||||||
|
|
||||||
|
// ─── Character widths — EXACT, extracted from gg sans Regular.ttf ───────────
|
||||||
|
// Source: hmtx table advance widths / unitsPerEm (1000). Generated via
|
||||||
|
// fontTools. Unknown characters fall back to FALLBACK_WIDTH (average of
|
||||||
|
// lowercase letters, a reasonable default for unmeasured glyphs).
|
||||||
|
|
||||||
|
const CHAR_WIDTHS: Record<string, number> = {
|
||||||
|
"A": 0.644, "B": 0.612, "C": 0.636, "D": 0.658, "E": 0.56,
|
||||||
|
"F": 0.545, "G": 0.67, "H": 0.67, "I": 0.242, "J": 0.513,
|
||||||
|
"K": 0.605, "L": 0.556, "M": 0.838, "N": 0.681, "O": 0.684,
|
||||||
|
"P": 0.607, "Q": 0.684, "R": 0.623, "S": 0.556, "T": 0.532,
|
||||||
|
"U": 0.668, "V": 0.602, "W": 0.816, "X": 0.588, "Y": 0.59,
|
||||||
|
"Z": 0.551,
|
||||||
|
"a": 0.509, "b": 0.553, "c": 0.504, "d": 0.553, "e": 0.507,
|
||||||
|
"f": 0.337, "g": 0.503, "h": 0.538, "i": 0.228, "j": 0.228,
|
||||||
|
"k": 0.488, "l": 0.253, "m": 0.812, "n": 0.528, "o": 0.53,
|
||||||
|
"p": 0.553, "q": 0.553, "r": 0.377, "s": 0.452, "t": 0.383,
|
||||||
|
"u": 0.528, "v": 0.472, "w": 0.702, "x": 0.458, "y": 0.47,
|
||||||
|
"z": 0.438,
|
||||||
|
"0": 0.584, "1": 0.356, "2": 0.519, "3": 0.546, "4": 0.556,
|
||||||
|
"5": 0.536, "6": 0.535, "7": 0.458, "8": 0.528, "9": 0.535,
|
||||||
|
" ": 0.22, ".": 0.242, ",": 0.242, "'": 0.178, "!": 0.234,
|
||||||
|
"»": 0.462, "«": 0.462, "-": 0.408, "_": 0.508, "/": 0.438,
|
||||||
|
"[": 0.31, "]": 0.31, ":": 0.242,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_WIDTH = 0.5; // reasonable default for unmeasured characters
|
||||||
|
|
||||||
|
// Estimated width of the invisible filler character itself, relative to
|
||||||
|
// one standard (uppercase) letter, SPECIFICALLY INSIDE EMBEDS using Thin
|
||||||
|
// Space (U+2009). CALIBRATED via live embed testing — see file header.
|
||||||
|
const FILLER_WIDTH = 0.203;
|
||||||
|
|
||||||
|
// Discord custom emoji tags <:name:id> or <a:name:id> (animated) render as
|
||||||
|
// ONE fixed-width visual glyph regardless of how long the internal name/ID
|
||||||
|
// text happens to be. Without this, estimateWidth would wrongly measure
|
||||||
|
// the RAW TAG TEXT character-by-character, making emoji with longer
|
||||||
|
// internal names appear "wider" than equally-sized emoji with shorter
|
||||||
|
// names — a category error, since emoji width has nothing to do with
|
||||||
|
// their Discord ID string length.
|
||||||
|
const EMOJI_TAG_REGEX = /<a?:\w+:\d+>/g;
|
||||||
|
|
||||||
|
// Estimated visual width of a single custom emoji, roughly equivalent to
|
||||||
|
// one full em (similar to a wide character). Approximate — emoji can vary
|
||||||
|
// slightly by their actual artwork, but this is consistent enough for
|
||||||
|
// alignment purposes.
|
||||||
|
const EMOJI_WIDTH = 1.0;
|
||||||
|
|
||||||
|
function charWidth(ch: string): number {
|
||||||
|
if (ch === FILLER) return FILLER_WIDTH;
|
||||||
|
return CHAR_WIDTHS[ch] ?? FALLBACK_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TextAlign = {
|
||||||
|
/**
|
||||||
|
* Estimate the visual width of a string in "em units" (1.0 = one full em).
|
||||||
|
* Uses exact gg sans font metrics for text. Discord custom emoji tags
|
||||||
|
* (<:name:id>) are detected and treated as ONE fixed-width unit each,
|
||||||
|
* NOT measured character-by-character (their internal ID text has no
|
||||||
|
* bearing on the emoji's actual rendered width).
|
||||||
|
*/
|
||||||
|
estimateWidth(text: string): number {
|
||||||
|
let total = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(EMOJI_TAG_REGEX)) {
|
||||||
|
const matchStart = match.index!;
|
||||||
|
const plainText = text.slice(lastIndex, matchStart);
|
||||||
|
total += plainText.split("").reduce((sum, ch) => sum + charWidth(ch), 0);
|
||||||
|
total += EMOJI_WIDTH;
|
||||||
|
lastIndex = matchStart + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = text.slice(lastIndex);
|
||||||
|
total += remaining.split("").reduce((sum, ch) => sum + charWidth(ch), 0);
|
||||||
|
|
||||||
|
return total;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the max estimated width across a list of strings.
|
||||||
|
*/
|
||||||
|
maxWidth(texts: string[]): number {
|
||||||
|
return Math.max(...texts.map((t) => TextAlign.estimateWidth(t)), 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad a string with invisible filler characters to reach a target width.
|
||||||
|
*/
|
||||||
|
pad(text: string, targetWidth: number): string {
|
||||||
|
const current = TextAlign.estimateWidth(text);
|
||||||
|
const diff = targetWidth - current;
|
||||||
|
const fillerCount = diff > 0 ? Math.round(diff / FILLER_WIDTH) : 0;
|
||||||
|
log.debug(`"${text}": estimatedWidth=${current.toFixed(3)} target=${targetWidth.toFixed(3)} diff=${diff.toFixed(3)} fillerCount=${fillerCount}`);
|
||||||
|
if (fillerCount <= 0) return text;
|
||||||
|
return text + FILLER.repeat(fillerCount);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad a string with invisible filler characters BEFORE it (prefix),
|
||||||
|
* so a fixed-width value (e.g. before a separator) ends up
|
||||||
|
* right-aligned relative to the target width.
|
||||||
|
*/
|
||||||
|
padLeft(text: string, targetWidth: number): string {
|
||||||
|
const current = TextAlign.estimateWidth(text);
|
||||||
|
const diff = targetWidth - current;
|
||||||
|
const fillerCount = diff > 0 ? Math.round(diff / FILLER_WIDTH) : 0;
|
||||||
|
if (fillerCount <= 0) return text;
|
||||||
|
return FILLER.repeat(fillerCount) + text;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad a string to match the widest string in a list (convenience).
|
||||||
|
*/
|
||||||
|
padToMax(text: string, allTexts: string[]): string {
|
||||||
|
return TextAlign.pad(text, TextAlign.maxWidth(allTexts));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad-left a string to match the widest string in a list (convenience).
|
||||||
|
*/
|
||||||
|
padLeftToMax(text: string, allTexts: string[]): string {
|
||||||
|
return TextAlign.padLeft(text, TextAlign.maxWidth(allTexts));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad a string to match the widest string in a list, with a small
|
||||||
|
* width offset applied to the target — for fine-tuning a specific
|
||||||
|
* value's alignment relative to a shared column without affecting
|
||||||
|
* the column's target width for other values.
|
||||||
|
* offset > 0 pads MORE (pushes right), offset < 0 pads LESS (pulls left).
|
||||||
|
*/
|
||||||
|
padToMaxOffset(text: string, allTexts: string[], offset: number): string {
|
||||||
|
return TextAlign.pad(text, TextAlign.maxWidth(allTexts) + offset);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pad-left variant of padToMaxOffset.
|
||||||
|
*/
|
||||||
|
padLeftToMaxOffset(text: string, allTexts: string[], offset: number): string {
|
||||||
|
return TextAlign.padLeft(text, TextAlign.maxWidth(allTexts) + offset);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get N filler characters as a standalone spacing buffer, for adding
|
||||||
|
* extra breathing room between columns beyond what alignment requires.
|
||||||
|
*/
|
||||||
|
gap(n: number = 1): string {
|
||||||
|
return FILLER.repeat(Math.max(0, n));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -38,7 +38,10 @@
|
||||||
"@ui/poll": ["src/ui/poll/index"],
|
"@ui/poll": ["src/ui/poll/index"],
|
||||||
"@ui/types": ["src/ui/types"],
|
"@ui/types": ["src/ui/types"],
|
||||||
"@discord": ["src/discord/index"],
|
"@discord": ["src/discord/index"],
|
||||||
"@discord/*": ["src/discord/*"]
|
"@discord/*": ["src/discord/*"],
|
||||||
|
"@ui/layout": ["src/ui/layout"],
|
||||||
|
"@ui/result": ["src/ui/result/index"],
|
||||||
|
"@ui/leaderboard": ["src/ui/leaderboard/index"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "scripts/**/*"],
|
"include": ["src/**/*", "scripts/**/*"],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue