From 9e8877483d2823143f85e4078b3fbf13e3d3ad19 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Sat, 20 Jun 2026 03:04:52 +0100 Subject: [PATCH] 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. --- .gitignore | 1 + data/emojis/anima-mastery.json | 8 + data/emojis/misc.json | 4 +- data/emojis/wrank-down.json | 3 +- data/emojis/wrank-up.json | 3 +- data/emojis/wrank.json | 3 +- messages/emojis.json | 13 +- package.json | 4 +- scripts/upload-emojis.ts | 19 +- src/commands/tg.ts | 19 ++ src/commands/tgAdmin.ts | 113 ++++++-- src/commands/tgConfig.ts | 27 +- src/discord/client.ts | 24 ++ src/discord/index.ts | 27 +- src/handlers/autocomplete.ts | 12 +- src/index.ts | 20 +- src/subcommands/admin/result-post.ts | 77 ++++++ src/subcommands/admin/score-inject.ts | 56 ++++ src/subcommands/admin/test-align.ts | 42 +++ src/subcommands/poll/call.ts | 38 +++ src/subcommands/poll/confirm-no.ts | 33 +++ src/subcommands/score/get.ts | 14 +- src/subcommands/score/submitCore.ts | 17 +- src/subcommands/tg-config/set-layout.ts | 41 ++- .../tg-config/set-leaderboard-layout.ts | 38 +++ .../tg-config/set-result-layout.ts | 38 +++ src/systems/config.ts | 59 ++-- src/systems/format.ts | 139 +++++++++- src/systems/leaderboard.ts | 197 +++++++++++++ src/systems/persistent-message.ts | 155 ++++++++--- src/systems/registry/character-registry.ts | 44 +-- src/systems/result.ts | 259 ++++++++++++++++++ src/systems/runtime.ts | 31 +++ src/systems/scheduler/midnight-results.ts | 29 ++ src/systems/score.ts | 13 +- src/systems/scores.ts | 6 +- src/systems/wrank.ts | 14 +- src/types.ts | 17 +- src/ui/embed-helpers.ts | 133 +++++++++ src/ui/layout.ts | 126 +++++++++ src/ui/leaderboard/highlights.ts | 70 +++++ src/ui/leaderboard/index.ts | 121 ++++++++ src/ui/leaderboard/layouts/default.ts | 86 ++++++ .../layouts/horizontal-combined.ts | 67 +++++ .../horizontal-sequential-extra-stats.ts | 143 ++++++++++ .../layouts/horizontal-sequential-stacked.ts | 79 ++++++ .../layouts/horizontal-sequential.ts | 105 +++++++ .../layouts/side-by-side-sequential.ts | 116 ++++++++ .../layouts/side-by-side-stacked.ts | 87 ++++++ src/ui/leaderboard/layouts/side-by-side.ts | 91 ++++++ .../leaderboard/layouts/stacked-tg-bottom.ts | 75 +++++ .../leaderboard/layouts/stacked-tg-score.ts | 73 +++++ src/ui/leaderboard/layouts/stacked-tg-top.ts | 74 +++++ .../leaderboard/layouts/stacked-with-rank.ts | 85 ++++++ src/ui/poll/base-layout.ts | 45 +-- src/ui/poll/layouts.bak/default.ts | 212 -------------- src/ui/poll/layouts.bak/side-by-side.ts | 197 ------------- src/ui/result/index.ts | 131 +++++++++ src/ui/result/layouts/default.ts | 94 +++++++ src/ui/result/layouts/inline.ts | 91 ++++++ src/ui/result/layouts/sequential.ts | 134 +++++++++ src/ui/text-align.ts | 183 +++++++++++++ tsconfig.json | 5 +- 63 files changed, 3673 insertions(+), 607 deletions(-) create mode 100644 data/emojis/anima-mastery.json create mode 100644 src/discord/client.ts create mode 100644 src/subcommands/admin/result-post.ts create mode 100644 src/subcommands/admin/score-inject.ts create mode 100644 src/subcommands/admin/test-align.ts create mode 100644 src/subcommands/poll/call.ts create mode 100644 src/subcommands/poll/confirm-no.ts create mode 100644 src/subcommands/tg-config/set-leaderboard-layout.ts create mode 100644 src/subcommands/tg-config/set-result-layout.ts create mode 100644 src/systems/leaderboard.ts create mode 100644 src/systems/result.ts create mode 100644 src/systems/scheduler/midnight-results.ts create mode 100644 src/ui/embed-helpers.ts create mode 100644 src/ui/layout.ts create mode 100644 src/ui/leaderboard/highlights.ts create mode 100644 src/ui/leaderboard/index.ts create mode 100644 src/ui/leaderboard/layouts/default.ts create mode 100644 src/ui/leaderboard/layouts/horizontal-combined.ts create mode 100644 src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts create mode 100644 src/ui/leaderboard/layouts/horizontal-sequential-stacked.ts create mode 100644 src/ui/leaderboard/layouts/horizontal-sequential.ts create mode 100644 src/ui/leaderboard/layouts/side-by-side-sequential.ts create mode 100644 src/ui/leaderboard/layouts/side-by-side-stacked.ts create mode 100644 src/ui/leaderboard/layouts/side-by-side.ts create mode 100644 src/ui/leaderboard/layouts/stacked-tg-bottom.ts create mode 100644 src/ui/leaderboard/layouts/stacked-tg-score.ts create mode 100644 src/ui/leaderboard/layouts/stacked-tg-top.ts create mode 100644 src/ui/leaderboard/layouts/stacked-with-rank.ts delete mode 100644 src/ui/poll/layouts.bak/default.ts delete mode 100644 src/ui/poll/layouts.bak/side-by-side.ts create mode 100644 src/ui/result/index.ts create mode 100644 src/ui/result/layouts/default.ts create mode 100644 src/ui/result/layouts/inline.ts create mode 100644 src/ui/result/layouts/sequential.ts create mode 100644 src/ui/text-align.ts diff --git a/.gitignore b/.gitignore index fc1d38d..486dd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ data/sessionPreferences.json data/tg-history/ data/updates/.message-ids.json data/.message-ids/ +data/snapshots/ # Emoji data emoji-uploads/ diff --git a/data/emojis/anima-mastery.json b/data/emojis/anima-mastery.json new file mode 100644 index 0000000..039c5f0 --- /dev/null +++ b/data/emojis/anima-mastery.json @@ -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>" +} \ No newline at end of file diff --git a/data/emojis/misc.json b/data/emojis/misc.json index fbcbd21..3e2b8cd 100644 --- a/data/emojis/misc.json +++ b/data/emojis/misc.json @@ -10,5 +10,7 @@ "wrank_down": "<:wrank_down:1511906547104616643>", "wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>", "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>" } \ No newline at end of file diff --git a/data/emojis/wrank-down.json b/data/emojis/wrank-down.json index 80658bb..abb6fc3 100644 --- a/data/emojis/wrank-down.json +++ b/data/emojis/wrank-down.json @@ -98,5 +98,6 @@ "wrank_down_96": "<:wrank_down_96:1513360449276477509>", "wrank_down_97": "<:wrank_down_97:1513360453152280709>", "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>" } \ No newline at end of file diff --git a/data/emojis/wrank-up.json b/data/emojis/wrank-up.json index ea14a4c..139554d 100644 --- a/data/emojis/wrank-up.json +++ b/data/emojis/wrank-up.json @@ -98,5 +98,6 @@ "wrank_up_96": "<:wrank_up_96:1513360762666746008>", "wrank_up_97": "<:wrank_up_97:1513360766588424192>", "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>" } \ No newline at end of file diff --git a/data/emojis/wrank.json b/data/emojis/wrank.json index 5859f94..b3a9d51 100644 --- a/data/emojis/wrank.json +++ b/data/emojis/wrank.json @@ -18,5 +18,6 @@ "wrank_6": "<:wrank_6:1512124952738795581>", "wrank_7": "<:wrank_7:1512124956622979143>", "wrank_8": "<:wrank_8:1512124961450496020>", - "wrank_9": "<:wrank_9:1512124965363650631>" + "wrank_9": "<:wrank_9:1512124965363650631>", + "wrank_0": "<:wrank_0:1516648016008712243>" } \ No newline at end of file diff --git a/messages/emojis.json b/messages/emojis.json index bb13f27..9da9679 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -262,5 +262,16 @@ "wrank_up_96": "<:wrank_up_96:1513360762666746008>", "wrank_up_97": "<:wrank_up_97:1513360766588424192>", "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>" } \ No newline at end of file diff --git a/package.json b/package.json index 69e626f..098debd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "start": "ts-node -r tsconfig-paths/register src/index.ts", "dev": "nodemon", "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": { "discord.js": "^14.15.3", diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts index 54bdf32..452d83e 100644 --- a/scripts/upload-emojis.ts +++ b/scripts/upload-emojis.ts @@ -31,11 +31,13 @@ } } - const TOKEN = process.env.DISCORD_TOKEN!; - const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds"); + Config.load(); + + const TOKEN = process.env.DISCORD_TOKEN!; + const DONOR_GUILD_IDS = Config.get({ section: "emoji", key: "donorGuilds" }); 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); } @@ -50,11 +52,12 @@ // Custom naming functions per dir — (filename without ext) → emoji name const DIR_NAME_MAP: Record string> = { - "wrank": (f) => `wrank_${f}`, - "wrank_gold": (f) => `wrank_${f}_gold`, - "wrank_up": (f) => `wrank_up_${f}`, - "wrank_down": (f) => `wrank_down_${f}`, - "wrank_x": (f) => `wrank_x_${f}`, + "wrank": (f) => `wrank_${f}`, + "wrank_gold": (f) => `wrank_${f}_gold`, + "wrank_up": (f) => `wrank_up_${f}`, + "wrank_down": (f) => `wrank_down_${f}`, + "wrank_x": (f) => `wrank_x_${f}`, + "anima-mastery_stats": (f) => `anima_${f}`, }; function resolveEmojiName(dirName: string, filename: string): string { diff --git a/src/commands/tg.ts b/src/commands/tg.ts index 99d1718..83d813d 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -57,11 +57,27 @@ import { handleCharActive } from "@subcommands/char/active"; import { Nation } from "@types"; 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 { const cmd = new SlashCommandBuilder() .setName("tg") .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 ───────────────────────────────────────────────────────────── cmd.addSubcommandGroup((g) => g .setName("poll") @@ -327,6 +343,7 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction): if (sub === "seed") return handleSeed(interaction); if (sub === "mark-left") return handleMarkLeft(interaction); if (sub === "unmark-left") return handleUnmarkLeft(interaction); + if (sub === "confirm-no") return ConfirmNoCommands.confirmNo(interaction); } if (group === "score") { 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 === "history") return handleHistory(interaction); if (!group && sub === "impersonate") return handleImpersonate(interaction); + if (!group && sub === "call") return CallCommands.call(interaction); + } diff --git a/src/commands/tgAdmin.ts b/src/commands/tgAdmin.ts index bc61020..843ed1e 100644 --- a/src/commands/tgAdmin.ts +++ b/src/commands/tgAdmin.ts @@ -5,7 +5,10 @@ import { handleAdminPollShowEntry, } 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 { const cmd = new SlashCommandBuilder() @@ -71,39 +74,96 @@ export function buildTgAdminCommand(): SlashCommandBuilder { ); cmd.addSubcommandGroup((g) => g - .setName("updates") - .setDescription("Manage update posts") + .setName("updates") + .setDescription("Manage update posts") + .addSubcommand((s) => s + .setName("post") + .setDescription("Post or edit an update embed") + .addStringOption((o) => o + .setName("version") + .setDescription("Version to post (defaults to latest)") + .setRequired(false) + .setAutocomplete(true) + ) + ) + .addSubcommand((s) => s + .setName("preview") + .setDescription("Preview an update embed (ephemeral)") + .addStringOption((o) => o + .setName("version") + .setDescription("Version to preview (defaults to latest)") + .setRequired(false) + .setAutocomplete(true) + ) + ) + .addSubcommand((s) => s + .setName("list") + .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 an update embed") - .addStringOption((o) => o - .setName("version") - .setDescription("Version to post (defaults to latest)") - .setRequired(false) - .setAutocomplete(true) - ) + .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("preview") - .setDescription("Preview an update embed (ephemeral)") - .addStringOption((o) => o - .setName("version") - .setDescription("Version to preview (defaults to latest)") - .setRequired(false) - .setAutocomplete(true) - ) - ) - .addSubcommand((s) => s - .setName("list") - .setDescription("List all versions and their post status") + .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; } export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise { - const group = interaction.options.getSubcommandGroup(true); + const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); if (group === "user") { @@ -122,4 +182,11 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract if (sub === "preview") return UpdatesCommands.preview(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); } \ No newline at end of file diff --git a/src/commands/tgConfig.ts b/src/commands/tgConfig.ts index 2b676da..ddf2b5e 100644 --- a/src/commands/tgConfig.ts +++ b/src/commands/tgConfig.ts @@ -4,6 +4,9 @@ import { hasOfficerRole } from "../systems/users"; import { replyAndDelete } from "../utils"; import { Nation } from "@types"; 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"]> = { 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; } @@ -137,7 +158,7 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac 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 roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { @@ -242,4 +263,8 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac if (group === "poll") { 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); } \ No newline at end of file diff --git a/src/discord/client.ts b/src/discord/client.ts new file mode 100644 index 0000000..8e5415f --- /dev/null +++ b/src/discord/client.ts @@ -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 +}; \ No newline at end of file diff --git a/src/discord/index.ts b/src/discord/index.ts index 83cc469..a4cff05 100644 --- a/src/discord/index.ts +++ b/src/discord/index.ts @@ -9,17 +9,20 @@ * Discord.Channel.fetch({ client, id }) */ -export { Interaction } from "./interaction"; -export { Guild } from "./guild"; -export { Channel } from "./channel"; + export { Interaction } from "./interaction"; + export { Guild } from "./guild"; + export { Channel } from "./channel"; + + // Top-level namespace for convenience + import { Interaction } from "./interaction"; + import { Guild } from "./guild"; + import { Channel } from "./channel"; -// Top-level namespace for convenience -import { Interaction } from "./interaction"; -import { Guild } from "./guild"; -import { Channel } from "./channel"; + // ─────────────────────────────────────────────────────────────────────────────── + + export const Discord = { + Interaction, + Guild, + Channel, + }; -export const Discord = { - Interaction, - Guild, - Channel, -}; diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index c03dca7..59fdbe7 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -10,6 +10,9 @@ import { Nation } from "@types"; import { NATION_UNICODE } from "@systems/nations"; import { autocompleteLayout } from "@subcommands/tg-config/set-layout"; 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"; // ─── Usermap cache ──────────────────────────────────────────────────────────── @@ -129,8 +132,15 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction): if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); - if (optionName === "layout") return await autocompleteLayout(interaction); 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([]); } catch (err) { diff --git a/src/index.ts b/src/index.ts index 17b9af6..6fd1b6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,11 @@ import { TGSlot } from "@src/types"; import { persist } from "@systems/pollPersistence" import { buildTgAdminCommand } from "@commands/tgAdmin"; 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 CLIENT_ID = process.env.CLIENT_ID!; @@ -73,6 +77,20 @@ client.once("clientReady", async () => { 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(); if (restored) { for (const [slot, state] of restored) polls.set(slot, state); diff --git a/src/subcommands/admin/result-post.ts b/src/subcommands/admin/result-post.ts new file mode 100644 index 0000000..f2b64c7 --- /dev/null +++ b/src/subcommands/admin/result-post.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, +}; \ No newline at end of file diff --git a/src/subcommands/admin/score-inject.ts b/src/subcommands/admin/score-inject.ts new file mode 100644 index 0000000..b775859 --- /dev/null +++ b/src/subcommands/admin/score-inject.ts @@ -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 { + 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); + }, +}; \ No newline at end of file diff --git a/src/subcommands/admin/test-align.ts b/src/subcommands/admin/test-align.ts new file mode 100644 index 0000000..b7fc6ec --- /dev/null +++ b/src/subcommands/admin/test-align.ts @@ -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 = { + hangul: "ㅤ", // U+3164 Hangul Filler + thin: "\u2009", // Thin Space + hair: "\u200a", // Hair Space + }; + + export async function handleTestAlign(interaction: ChatInputCommandInteraction): Promise { + 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, + }; \ No newline at end of file diff --git a/src/subcommands/poll/call.ts b/src/subcommands/poll/call.ts new file mode 100644 index 0000000..595dcc0 --- /dev/null +++ b/src/subcommands/poll/call.ts @@ -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 { + 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, +}; \ No newline at end of file diff --git a/src/subcommands/poll/confirm-no.ts b/src/subcommands/poll/confirm-no.ts new file mode 100644 index 0000000..a6ebf63 --- /dev/null +++ b/src/subcommands/poll/confirm-no.ts @@ -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 { + 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, +}; \ No newline at end of file diff --git a/src/subcommands/score/get.ts b/src/subcommands/score/get.ts index 7eb8ec3..9293e49 100644 --- a/src/subcommands/score/get.ts +++ b/src/subcommands/score/get.ts @@ -50,13 +50,13 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction): ? `\n*(played by ${(score as any).playedBy})*` : ""; - const lines = [ - `**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`, - `${scoreEmoji} **${score.pts}** pts`, - score.atk !== undefined ? `ATK: ${score.atk}` : null, - score.def !== undefined ? `DEF: ${score.def}` : null, - score.heal !== undefined ? `HEAL: ${score.heal}` : null, - `*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`, + const lines = [ + `**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`, + `${scoreEmoji} **${score.pts}** pts`, + score.stats?.atk !== undefined ? `ATK: ${score.stats.atk}` : null, + score.stats?.def !== undefined ? `DEF: ${score.stats.def}` : 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" })}*`, ].filter(Boolean).join("\n"); return void replyAndDelete(interaction, lines, true); diff --git a/src/subcommands/score/submitCore.ts b/src/subcommands/score/submitCore.ts index 8eee99c..6d906d4 100644 --- a/src/subcommands/score/submitCore.ts +++ b/src/subcommands/score/submitCore.ts @@ -68,7 +68,6 @@ export namespace score { const scoreEmoji = getEmoji("score") || "📊"; const kdEmoji = getEmoji("kd") || "⚔️"; - const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : ""; const statsNote = [ atk !== undefined ? `ATK: ${atk}` : null, @@ -77,10 +76,22 @@ export namespace score { ].filter(Boolean).join(" · "); const charDisplay = format.char(char); + const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; + + const line = format.scoreSubmitLine({ + slot, + char, + pts, + k, + d, + atk, + def, + heal, + }); + return { ok: true, - message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, - // message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + message: `✅ ${line}${borrowNote}`, }; } } \ No newline at end of file diff --git a/src/subcommands/tg-config/set-layout.ts b/src/subcommands/tg-config/set-layout.ts index eb028a1..805ceed 100644 --- a/src/subcommands/tg-config/set-layout.ts +++ b/src/subcommands/tg-config/set-layout.ts @@ -1,47 +1,38 @@ import { ChatInputCommandInteraction } from "discord.js"; import { Config } from "@systems/config"; import { PollUI } from "@ui/poll"; -import { replyAndDelete } from "@utils"; +import { Discord } from "@discord"; import { hasOfficerRole } from "@systems/users"; export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise { - // 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); 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)) { - const available = PollUI.layouts() - .map((l) => `\`${l.name}\` — ${l.description}`) - .join("\n"); - return void replyAndDelete(interaction, - `❌ Layout \`${name}\` not found. Available layouts:\n${available}`, true - ); + const available = PollUI.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: "poll", key: "layout", value: name }); - - return void replyAndDelete(interaction, - `✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true - ); + await Discord.Interaction.reply(interaction, { content: `✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, ephemeral: true }); } export async function autocompleteLayout(interaction: any): Promise { - // 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 focused = options.getFocused().toLowerCase(); + const focused = interaction.options.getFocused().toLowerCase(); const choices = PollUI.layouts() .filter((l) => l.name.includes(focused)) .map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name })); await interaction.respond(choices); -} \ No newline at end of file +} + +export const SetLayoutCommands = { + handle: handleSetLayout, + autocomplete: autocompleteLayout, +}; \ No newline at end of file diff --git a/src/subcommands/tg-config/set-leaderboard-layout.ts b/src/subcommands/tg-config/set-leaderboard-layout.ts new file mode 100644 index 0000000..93ede8f --- /dev/null +++ b/src/subcommands/tg-config/set-leaderboard-layout.ts @@ -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 { + 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 { + 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, +}; \ No newline at end of file diff --git a/src/subcommands/tg-config/set-result-layout.ts b/src/subcommands/tg-config/set-result-layout.ts new file mode 100644 index 0000000..c9e62a5 --- /dev/null +++ b/src/subcommands/tg-config/set-result-layout.ts @@ -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 { + 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 { + 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, +}; \ No newline at end of file diff --git a/src/systems/config.ts b/src/systems/config.ts index 2d4229c..da45dc4 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -9,16 +9,18 @@ Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 } // ─── Section interfaces (internal) ─────────────────────────────────────────── interface ChannelConfig { - poll: string; - results: string; - score: string; - updates: string; + poll: string; + results: string; + score: string; + updates: string; + leaderboard: string; } interface RoleConfig { - officer: string[]; - config: string[]; - tag: string[]; + officer: string[]; + config: string[]; + tag: string[]; + callGame: string[]; } interface PollConfig { @@ -38,9 +40,20 @@ interface PollConfig { autoVoteOnConflict: boolean; reclaimNotifyBorrower: boolean; conflictReclaimBehavior: string; + calledGameImageUrl?: string; + cancelledImageUrl?: string; + calledMessage?: string; slots: TGSlot[]; } +interface ResultConfig { + layout: string; +} + +interface LeaderboardConfig { + layout: string; +} + interface WRankConfig { goal: number; postOnReset: boolean; @@ -81,16 +94,18 @@ interface TGConfig { // ─── Section map ────────────────────────────────────────────────────────────── export interface SectionMap { - channels: ChannelConfig; - roles: RoleConfig; - poll: PollConfig; - wrank: WRankConfig; - bringer: BringerConfig; - impersonate:ImpersonateConfig; - emoji: EmojiConfig; - nation: NationConfig; - borrow: BorrowConfig; - tg: TGConfig; + channels: ChannelConfig; + roles: RoleConfig; + poll: PollConfig; + result: ResultConfig; + leaderboard: LeaderboardConfig; + wrank: WRankConfig; + bringer: BringerConfig; + impersonate: ImpersonateConfig; + emoji: EmojiConfig; + nation: NationConfig; + borrow: BorrowConfig; + tg: TGConfig; } export type ConfigSection = keyof SectionMap; @@ -103,12 +118,14 @@ function getDefaults(): SectionMap { poll: "", results: "", score: "", - updates: "" + updates: "", + leaderboard: "" }, roles: { officer: ["Ice King"], config: ["Ice King"], tag: ["Ice King", "Ice", "Rebellion"], + callGame: ["Ice King"], }, poll: { layout: "default", @@ -129,6 +146,12 @@ function getDefaults(): SectionMap { conflictReclaimBehavior: "revert", slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], }, + result: { + layout: "default" + }, + leaderboard: { + layout: "default" + }, wrank: { goal: 7, postOnReset: false, diff --git a/src/systems/format.ts b/src/systems/format.ts index 1b3e113..01058d9 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -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 { WRankEntry } from "@systems/wrank"; +import { LeaderboardEntry } from "./leaderboard"; // ─── Individual formatters ──────────────────────────────────────────────────── @@ -21,7 +22,8 @@ function charButton(c: Character, options?: { shared?: boolean }): string { function char(c: Character, options?: CharDisplayOptions): string { const showEmoji = options?.emoji ?? 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 levelStr = showLevel ? `${c.level} ` : ""; 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")); } +// ─── 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. + * " (20:00 TG) WI 79 »Flash« 2000 18/4 X X 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 ──────────────────────────────────────────────────────── function bringerDisplay(n: Nation): string { @@ -161,11 +259,48 @@ export const format = { score, emoji, date, + number: { + abbrev: abbrev, + colored: colorNumber + }, wrank: { rank: wrankRank, delta: wrankDelta, full: wrankFull, 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, }; \ No newline at end of file diff --git a/src/systems/leaderboard.ts b/src/systems/leaderboard.ts new file mode 100644 index 0000000..1ae7548 --- /dev/null +++ b/src/systems/leaderboard.ts @@ -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 { + 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 { + 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}`); + }, + }; \ No newline at end of file diff --git a/src/systems/persistent-message.ts b/src/systems/persistent-message.ts index 2831e3f..2845373 100644 --- a/src/systems/persistent-message.ts +++ b/src/systems/persistent-message.ts @@ -1,21 +1,29 @@ /** * 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/ + * Slot snapshots live in data/snapshots/{store}/{key}/{slot}.json * - * Usage: - * import { PersistentMessage } from "@systems/persistent-message"; + * Usage (legacy, unchanged): + * await PersistentMessage.post({ store: "leaderboard", key: "2026-W24", channelId, embeds: [embed], client }); * - * await PersistentMessage.post({ - * store: "leaderboard", - * key: "2026-W24", - * channelId: "123456", - * embeds, - * client, + * Usage (slotted): + * PersistentMessage.registerSlot({ store: "leaderboard", slot: "main" }); + * PersistentMessage.registerSlot({ store: "leaderboard", slot: "highlights" }); + * + * await PersistentMessage.updateSlot({ + * 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 { Client, EmbedBuilder, TextChannel } from "discord.js"; import { Store } from "@systems/store"; @@ -52,7 +60,21 @@ 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 { return Paths.data(".message-ids", `${store}.json`); @@ -66,39 +88,46 @@ Store.write(storePath(store), data); } + // ─── Helpers — slot registry + snapshots ───────────────────────────────────── + + const _slotRegistry = new Map(); + + 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 ──────────────────────────────────────────────────────────────── export const PersistentMessage = { - /** - * Get the stored messageId for a key in a store. - */ + // ─── Legacy / simple API (unchanged) ───────────────────────────────────── + get({ store, key }: GetParams): string | null { return readStore(store)[key] ?? null; }, - /** - * Store a messageId for a key. - */ set({ store, key, messageId }: SetParams): void { - const data = readStore(store); - data[key] = messageId; + const data = readStore(store); + data[key] = messageId; writeStore(store, data); }, - /** - * Delete a stored messageId. - */ delete({ store, key }: DeleteParams): void { const data = readStore(store); delete data[key]; 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 { const channel = await client.channels.fetch(channelId) as TextChannel; const messageId = PersistentMessage.get({ store, key }); @@ -112,7 +141,7 @@ log.info(`Edited ${store}/${key} (${messageId})`); return; } 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 }); } } @@ -122,10 +151,74 @@ log.info(`Posted ${store}/${key} (${msg.id})`); }, - /** - * List all keys in a store. - */ list({ store }: { store: MessageStore }): string[] { 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 { + 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); + }, }; \ No newline at end of file diff --git a/src/systems/registry/character-registry.ts b/src/systems/registry/character-registry.ts index 9f3472c..0957a04 100644 --- a/src/systems/registry/character-registry.ts +++ b/src/systems/registry/character-registry.ts @@ -10,7 +10,6 @@ */ import fs from "fs"; - import path from "path"; import { Character, UserKey, CharName } from "@types"; import { Paths } from "@helpers/paths"; @@ -35,16 +34,16 @@ * Find a character by name across all users. * Character names are unique across users (enforced by conflict system). */ - find(charName: CharName): Character | null { - const chars = loadChars(); - for (const data of Object.values(chars)) { - const found = data.characters?.find( - (c) => c.name.toLowerCase() === charName.toLowerCase() - ); - if (found) return found; - } - return null; - }, + find(charName: CharName): Character | null { + const chars = loadChars(); + for (const [ownerKey, data] of Object.entries(chars)) { + const found = data.characters?.find( + (c) => c.name.toLowerCase() === charName.toLowerCase() + ); + if (found) return { ...found, ownerKey } as Character; + } + return null; + }, all(): Character[] { const chars = loadChars(); @@ -54,20 +53,21 @@ /** * Find a character by name for a specific user. */ - findForUser(userKey: UserKey, charName: CharName): Character | null { - const chars = loadChars(); - return chars[userKey]?.characters?.find( - (c) => c.name.toLowerCase() === charName.toLowerCase() - ) ?? null; - }, + findForUser(userKey: UserKey, charName: CharName): Character | null { + const chars = loadChars(); + const found = chars[userKey]?.characters?.find( + (c) => c.name.toLowerCase() === charName.toLowerCase() + ); + return found ? { ...found, ownerKey: userKey } as Character : null; + }, /** * Get all characters for a user. */ - forUser(userKey: UserKey): Character[] { - const chars = loadChars(); - return chars[userKey]?.characters ?? []; - }, + forUser(userKey: UserKey): Character[] { + const chars = loadChars(); + return (chars[userKey]?.characters ?? []).map((c) => ({ ...c, ownerKey: userKey } as Character)); + }, /** * Get the active character for a user (from characters.json only, no borrow). @@ -87,7 +87,7 @@ if (ownerKey === userKey) continue; for (const char of data.characters ?? []) { if (char.sharedWith?.includes(userKey)) { - result.push({ char, ownerKey }); + result.push({ char: { ...char, ownerKey } as Character, ownerKey }); } } } diff --git a/src/systems/result.ts b/src/systems/result.ts new file mode 100644 index 0000000..3e3b369 --- /dev/null +++ b/src/systems/result.ts @@ -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(); + 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 { + 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}`); + }, + }; \ No newline at end of file diff --git a/src/systems/runtime.ts b/src/systems/runtime.ts index 8182ebd..c0dca55 100644 --- a/src/systems/runtime.ts +++ b/src/systems/runtime.ts @@ -172,4 +172,35 @@ } } }, + }; + + // ─── Events ─────────────────────────────────────────────────────────────────── + + export type RuntimeEvent = + | "scoreSubmitted" + | "pollLocked" + | "pollConfirmed" + | "weekReset" + | "allScoresSubmitted"; + + type EventHandler = (payload: T) => void | Promise; + + const _eventHandlers = new Map(); + + export const RuntimeEvents = { + on(event: RuntimeEvent, handler: EventHandler): void { + if (!_eventHandlers.has(event)) _eventHandlers.set(event, []); + _eventHandlers.get(event)!.push(handler as EventHandler); + }, + + async emit(event: RuntimeEvent, payload?: T): Promise { + 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); + } + } + }, }; \ No newline at end of file diff --git a/src/systems/scheduler/midnight-results.ts b/src/systems/scheduler/midnight-results.ts new file mode 100644 index 0000000..e665950 --- /dev/null +++ b/src/systems/scheduler/midnight-results.ts @@ -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 }); + } + }, + }; \ No newline at end of file diff --git a/src/systems/score.ts b/src/systems/score.ts index cf6a81e..ff291a4 100644 --- a/src/systems/score.ts +++ b/src/systems/score.ts @@ -14,6 +14,7 @@ import { Store } from "@systems/store"; import { Paths } from "@helpers/paths"; import { TGKey } from "@systems/tg-key"; + import { RuntimeEvents } from "@systems/runtime"; export interface TGScore { 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({ character, slot }: { + get({ character, slot, historyKey }: { character: Character; slot: SlotHour; + historyKey?: TGKey; }): TGScore | null { - const historyKey = TGKey.current({ slot }); - const history = loadHistory(historyKey); + const key = historyKey ?? TGKey.current({ slot }); + const history = loadHistory(key); return history.scores.find( (s) => s.userKey === character.ownerKey && s.characterName === character.name ) ?? null; @@ -112,7 +114,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void { * Submit a score for a character. * 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; borrowedFrom?: UserKey; pts: number; @@ -123,7 +125,7 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void { heal?: number; slot: SlotHour; submittedByOfficer?: boolean; - }): void { + }): Promise { const date = new Date().toISOString().slice(0, 10); const historyKey = TGKey.current({ slot }); const history = loadHistory(historyKey); @@ -173,5 +175,6 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void { pts, historyKey ); + await RuntimeEvents.emit("scoreSubmitted", { historyKey, character }); }, }; \ No newline at end of file diff --git a/src/systems/scores.ts b/src/systems/scores.ts index cb625c8..b442551 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -83,9 +83,9 @@ export function submitScore(sub: ScoreSubmission): void { pts: sub.pts, k: sub.k, d: sub.d, - atk: sub.atk, - def: sub.def, - heal: sub.heal, + stats: sub.atk !== undefined || sub.def !== undefined || sub.heal !== undefined + ? { atk: sub.atk, def: sub.def, heal: sub.heal } + : undefined, submittedAt: new Date().toISOString(), slot: sub.slot, date, diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index a7814be..bbb10eb 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -139,6 +139,10 @@ export const WRank = { return _data[weekKey] ?? null; }, + allWeeks(): WRankData { + return _data; + }, + // ── Score recording ────────────────────────────────────────────────────────── recordScore( @@ -188,16 +192,16 @@ export const WRank = { // ── Entry lookup ───────────────────────────────────────────────────────────── - entry(characterName: CharName, nation: Nation): WRankEntry | null { - const week = WRank.currentWeek(); + entry(characterName: CharName, nation: Nation, weekKey?: string): WRankEntry | null { + const week = weekKey ? (_data[weekKey] ?? null) : WRank.currentWeek(); const list = week.entries[nation]; const raw = list.find((e) => e.characterName === characterName); return raw ? hydrateEntry(raw) : null; }, - entriesForNation(nation: Nation): WRankEntry[] { - const week = WRank.currentWeek(); - return week.entries[nation].map(hydrateEntry); + entriesForNation(nation: Nation, week?: WRankWeek): WRankEntry[] { + const _week = week ?? WRank.currentWeek(); + return _week.entries[nation].map(hydrateEntry); }, // ── Snapshot ───────────────────────────────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index 5acc41b..842bfc9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,6 +173,8 @@ export interface PollState { lockedYesKeys?: Set; lockMessage?: string; confirmMessage?: string; + called?: boolean; + calledAt?: string; } @@ -198,9 +200,7 @@ export interface TGScore { pts: number; k?: number; d?: number; - atk?: number; - def?: number; - heal?: number; + stats?: TGStats; submittedAt: string; // ISO timestamp slot: number; // TG hour date: string; // YYYY-MM-DD @@ -208,6 +208,12 @@ export interface TGScore { playedBy?: string; // userKey of who actually played (if borrowed) } +export interface TGStats { + atk?: number; + def?: number; + heal?: number; +} + // ─── TG Result ─────────────────────────────────────────────────────────────── export interface NationKD { @@ -251,6 +257,11 @@ export interface TGResult { // activeCharacter: Character | null; // } +export interface WRankPosition { + currentRank: number; + previousRank?: number; +} + // ─── Bringer ───────────────────────────────────────────────────────────────── export interface BringerState { diff --git a/src/ui/embed-helpers.ts b/src/ui/embed-helpers.ts new file mode 100644 index 0000000..0ef6d2c --- /dev/null +++ b/src/ui/embed-helpers.ts @@ -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, +}; \ No newline at end of file diff --git a/src/ui/layout.ts b/src/ui/layout.ts new file mode 100644 index 0000000..6f16598 --- /dev/null +++ b/src/ui/layout.ts @@ -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 { + 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, + }; + }, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/highlights.ts b/src/ui/leaderboard/highlights.ts new file mode 100644 index 0000000..e908361 --- /dev/null +++ b/src/ui/leaderboard/highlights.ts @@ -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}` }); + } \ No newline at end of file diff --git a/src/ui/leaderboard/index.ts b/src/ui/leaderboard/index.ts new file mode 100644 index 0000000..896198b --- /dev/null +++ b/src/ui/leaderboard/index.ts @@ -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(); + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/default.ts b/src/ui/leaderboard/layouts/default.ts new file mode 100644 index 0000000..8e9b45e --- /dev/null +++ b/src/ui/leaderboard/layouts/default.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/horizontal-combined.ts b/src/ui/leaderboard/layouts/horizontal-combined.ts new file mode 100644 index 0000000..de14cec --- /dev/null +++ b/src/ui/leaderboard/layouts/horizontal-combined.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts b/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts new file mode 100644 index 0000000..0bf58eb --- /dev/null +++ b/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/horizontal-sequential-stacked.ts b/src/ui/leaderboard/layouts/horizontal-sequential-stacked.ts new file mode 100644 index 0000000..9f98f06 --- /dev/null +++ b/src/ui/leaderboard/layouts/horizontal-sequential-stacked.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/horizontal-sequential.ts b/src/ui/leaderboard/layouts/horizontal-sequential.ts new file mode 100644 index 0000000..11d7bc4 --- /dev/null +++ b/src/ui/leaderboard/layouts/horizontal-sequential.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/side-by-side-sequential.ts b/src/ui/leaderboard/layouts/side-by-side-sequential.ts new file mode 100644 index 0000000..d9f6833 --- /dev/null +++ b/src/ui/leaderboard/layouts/side-by-side-sequential.ts @@ -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 = { + 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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/side-by-side-stacked.ts b/src/ui/leaderboard/layouts/side-by-side-stacked.ts new file mode 100644 index 0000000..6511e1b --- /dev/null +++ b/src/ui/leaderboard/layouts/side-by-side-stacked.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/side-by-side.ts b/src/ui/leaderboard/layouts/side-by-side.ts new file mode 100644 index 0000000..2c80420 --- /dev/null +++ b/src/ui/leaderboard/layouts/side-by-side.ts @@ -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 = { + wrank: Layout.wrank(wrEntry, goal, context), + class: Emoji.class(classKey) || classKey || "?", + name: char.name, + indicators: Layout.indicators(char as any), + }; + + const statsTokens: Record = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/stacked-tg-bottom.ts b/src/ui/leaderboard/layouts/stacked-tg-bottom.ts new file mode 100644 index 0000000..6cbd426 --- /dev/null +++ b/src/ui/leaderboard/layouts/stacked-tg-bottom.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/stacked-tg-score.ts b/src/ui/leaderboard/layouts/stacked-tg-score.ts new file mode 100644 index 0000000..021eff4 --- /dev/null +++ b/src/ui/leaderboard/layouts/stacked-tg-score.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/stacked-tg-top.ts b/src/ui/leaderboard/layouts/stacked-tg-top.ts new file mode 100644 index 0000000..7d84911 --- /dev/null +++ b/src/ui/leaderboard/layouts/stacked-tg-top.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/leaderboard/layouts/stacked-with-rank.ts b/src/ui/leaderboard/layouts/stacked-with-rank.ts new file mode 100644 index 0000000..ef93363 --- /dev/null +++ b/src/ui/leaderboard/layouts/stacked-with-rank.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/poll/base-layout.ts b/src/ui/poll/base-layout.ts index 4e38eed..1b87eba 100644 --- a/src/ui/poll/base-layout.ts +++ b/src/ui/poll/base-layout.ts @@ -17,19 +17,9 @@ // ─── W.Rank formatting ──────────────────────────────────────────────────────── - export function formatWRank( - wRankEntry: WRankEntry | null, - context: PollRowContext - ): string { - if (!wRankEntry || wRankEntry.currentRank === 0) { - if (!context.nationHasRank) return ""; - return format.wrank.noRank({ delta: context.nationHasDelta }); - } - - const goal = Config.get({ section: "wrank", key: "goal" }); - const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta; - return format.wrank.rank(wRankEntry, goal) + - format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder }); + export function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { + const tgGoal = Config.get({ section: "wrank", key: "goal" }); + return format.wrank.row(wRankEntry, tgGoal, context); } // ─── Row formatting ─────────────────────────────────────────────────────────── @@ -141,13 +131,15 @@ .join("\n"); } - export function resolveColor(state: PollState): number { - if (state.confirmed === "yes") return 0x57f287; - if (state.confirmed === "no") return 0xed4245; - if (state.locked) return 0x888888; - return 0xe8a317; - } +export function resolveColor(state: PollState): number { + if (state.confirmed === "yes") return 0x57f287; // green + if (state.confirmed === "no") return 0xed4245; // red + if ((state as any).called) return 0xe8a317; // orange + if (state.locked) return 0x888888; // grey + return 0xe8a317; // orange (open) +} + export function resolveTitle(state: PollState, yesByNation: Record): string { const capellaEmoji = Emoji.get("capella"); const procyonEmoji = Emoji.get("procyon"); @@ -157,17 +149,20 @@ const statusSuffix = state.locked ? " 🔒" : state.confirmed === "yes" ? " ✅" : - state.confirmed === "no" ? " ❌" : ""; + state.confirmed === "no" ? " ❌" : + (state as any).called ? " 🔔" : ""; + return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; } export 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 as any).called) return Config.get({ section: "poll", key: "calledMessage" }) ?? "🔔 TG was called."; if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; } - + export function buildYesByNation(state: PollState): { yesByNation: Record; noVoters: VoteEntry[]; @@ -219,6 +214,14 @@ .setTitle(resolveTitle(state, yesByNation)) .setColor(resolveColor(state)) .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) { // Side-by-side — no spacer needed diff --git a/src/ui/poll/layouts.bak/default.ts b/src/ui/poll/layouts.bak/default.ts deleted file mode 100644 index f9bc062..0000000 --- a/src/ui/poll/layouts.bak/default.ts +++ /dev/null @@ -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 - ): 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.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, - }; \ No newline at end of file diff --git a/src/ui/poll/layouts.bak/side-by-side.ts b/src/ui/poll/layouts.bak/side-by-side.ts deleted file mode 100644 index c6c56f3..0000000 --- a/src/ui/poll/layouts.bak/side-by-side.ts +++ /dev/null @@ -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.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, - }; \ No newline at end of file diff --git a/src/ui/result/index.ts b/src/ui/result/index.ts new file mode 100644 index 0000000..6cd8d6a --- /dev/null +++ b/src/ui/result/index.ts @@ -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(); + 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, + }; \ No newline at end of file diff --git a/src/ui/result/layouts/default.ts b/src/ui/result/layouts/default.ts new file mode 100644 index 0000000..c8a42de --- /dev/null +++ b/src/ui/result/layouts/default.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/result/layouts/inline.ts b/src/ui/result/layouts/inline.ts new file mode 100644 index 0000000..669be43 --- /dev/null +++ b/src/ui/result/layouts/inline.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/result/layouts/sequential.ts b/src/ui/result/layouts/sequential.ts new file mode 100644 index 0000000..c8efb3d --- /dev/null +++ b/src/ui/result/layouts/sequential.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/text-align.ts b/src/ui/text-align.ts new file mode 100644 index 0000000..c4561f1 --- /dev/null +++ b/src/ui/text-align.ts @@ -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 = { + "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 (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 = //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)); + }, + }; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index bc8b761..edcb420 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,10 @@ "@ui/poll": ["src/ui/poll/index"], "@ui/types": ["src/ui/types"], "@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/**/*"],