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:
Nuno Duque Nunes 2026-06-20 03:04:52 +01:00
parent b22602f431
commit 9e8877483d
63 changed files with 3673 additions and 607 deletions

1
.gitignore vendored
View file

@ -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/

View 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>"
}

View file

@ -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>"
} }

View file

@ -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>"
} }

View file

@ -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>"
} }

View file

@ -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>"
} }

View file

@ -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>"
} }

View file

@ -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",

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

@ -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);

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

View 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);
},
};

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

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

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

View file

@ -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");

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 });
}
},
};

View file

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

View file

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

View file

@ -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 ─────────────────────────────────────────────────────────────────

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -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(

View file

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

View file

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

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

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

View 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
View 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));
},
};

View file

@ -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/**/*"],