Compare commits

..

16 commits
master ... dev

Author SHA1 Message Date
Nuno Duque Nunes
7d68530826 fix Bringer showing incorrectly from previous weeks on result post 2026-06-22 15:41:28 +01:00
Nuno Duque Nunes
772477e6e8 place Bringer icon next to name in Leaderboard:sequential-extra-stats 2026-06-22 06:08:43 +01:00
Nuno Duque Nunes
2502b9e70b place Bringer icon next to name in Leaderboard:sequential-extra-stats 2026-06-22 06:06:00 +01:00
Nuno Duque Nunes
1e0c0c7344 fix Leaderboards Bringer.layout 2026-06-22 05:59:38 +01:00
Nuno Duque Nunes
8155722440 fix Leaderboards showing Bringer from previous weeks 2026-06-22 05:57:04 +01:00
Nuno Duque Nunes
78504b9f39 add Announcements system for upcoming features and general announcements 2026-06-22 05:36:44 +01:00
Nuno Duque Nunes
049ea7b77f fix: unify score submission on Score.submit, fix TGScore type drift, fix playedBy semantics for borrowed characters
- Score.submit/score/set.ts/score-inject.ts now all share one code path
- TGScore consolidated to single canonical type (was duplicated in types.ts and score.ts)
- Fixed atk/def/heal flat-vs-nested TGStats drift across leaderboard.ts, result layouts
- Fixed playedBy semantics — now correctly identifies the actual player on borrowed characters
- Attendance.allSubmitted now correctly matches against playedBy (borrower) not just userKey (owner)
- score-inject gained atk/def/heal/date/played_by parameters for full parity with real submission
- Added migrate-stats-shape.py and fix-class-keys.py maintenance scripts
2026-06-22 04:27:03 +01:00
Nuno Duque Nunes
da0f90f5d7 fix char autocomplete, /tg char active legacy code 2026-06-20 03:28:22 +01:00
Nuno Duque Nunes
9e8877483d 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.
2026-06-20 03:04:52 +01:00
Nuno Duque Nunes
b22602f431 feat: TGKey branded type, PersistentMessage abstraction, createBuildEmbed factory, BaseLayout refactor 2026-06-13 02:23:56 +01:00
Nuno Duque Nunes
a4d772b81d fix date() formatter 2026-06-12 23:24:15 +01:00
Nuno Duque Nunes
63e3a63a7c add date() formatter, add PersistentMessage feature, refactor updates to use PersistentMessage 2026-06-12 23:23:19 +01:00
Nuno Duque Nunes
c26d2047a9 chore: untrack messages/users files 2026-06-12 18:34:21 +01:00
Nuno Duque Nunes
6f10db832e update gitignore 2026-06-12 17:11:39 +01:00
Nuno Duque Nunes
d7a01d1c7f possibly fixed emojis broken display in update embeds 2026-06-12 17:03:38 +01:00
Nuno Duque Nunes
41c813661b test: revert systems/updates.ts 2026-06-12 16:56:57 +01:00
85 changed files with 4441 additions and 972 deletions

4
.gitignore vendored
View file

@ -19,7 +19,9 @@ data/attendance.json
data/leaves.json
data/sessionPreferences.json
data/tg-history/
data/updates/.message-ids.json
data/.message-ids/
data/snapshots/
# Emoji data
emoji-uploads/

View file

@ -0,0 +1,3 @@
{
"001-leaderboards-results-live": "1518473077330284676"
}

View file

@ -0,0 +1,30 @@
{
"id": "001-leaderboards-results-live",
"title": "🏆 Leaderboards & TG Results are now live!",
"date": "2026-06-22",
"intro": "Two new things you'll start seeing in the server:",
"color": "#e8a317",
"sections": [
{
"label": "Weekly Leaderboard",
"emoji": "<:score:1511906491903250525>",
"items": [
{ "text": "A live, always-updated ranking for the week" },
{ "text": "Every score you submit updates it instantly — check it anytime to see where you and your nation stand" }
]
},
{
"label": "TG Results",
"emoji": "<:anima_atk:1517702182710018179>",
"items": [
{ "text": "A clean breakdown of how everyone did after each TG — score, kills, deaths, and combat stats when recorded" },
{ "text": "Updates live as players submit, so you don't need to wait for everyone to finish" }
]
}
],
"channelLinks": [
{ "label": "<:score:1511906491903250525> Weekly Leaderboard", "channelKey": "leaderboard" },
{ "label": "<:anima_atk:1517702182710018179> TG Results", "channelKey": "results" }
],
"imageUrl": null
}

View file

@ -0,0 +1,8 @@
{
"anima_anima_atk": "<:anima_anima_atk:1517697805181784126>",
"anima_anima_def": "<:anima_anima_def:1517700444116484246>",
"anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>",
"anima_atk": "<:anima_atk:1517702182710018179>",
"anima_def": "<:anima_def:1517700561468657785>",
"anima_massheal": "<:anima_massheal:1517702186434433146>"
}

3
data/emojis/circle.json Normal file
View file

@ -0,0 +1,3 @@
{
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>"
}

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@
"userKey": "ayana",
"displayName": "Ayana",
"characterName": "«MonkeyHunter»",
"characterClass": "GL",
"characterClass": "DM",
"characterLevel": 79,
"characterNation": "Procyon",
"votedAt": "19:46"
@ -36,7 +36,7 @@
"no": [],
"leaves": [
{
"characterName": "»Flash«",
"characterName": " «Deystroyer»",
"historyKey": "2026-06-11-20"
}
],

View file

@ -0,0 +1,29 @@
{
"version": "v0.9.1",
"date": "2026-06-22",
"title": "Leaderboard & Result Live Updates Fix",
"layout": "default",
"sections": [
{
"type": "fix",
"label": "Fixes",
"emoji": "🔧",
"items": [
{ "text": "Fixed Leaderboard and Result not updating automatically when players submitted scores" },
{ "text": "Fixed TG Result never posting when a borrowed/shared character was used — attendance is now correctly tracked to the actual player, not just the character's owner" },
{ "text": "Fixed ATK / DEF / Heal stats not displaying on some Result layouts" },
{ "text": "Fixed a data inconsistency that could cause class icons to disappear from W.Rank or TG history entries" }
]
},
{
"type": "technical",
"label": "Under the hood",
"emoji": "🛠️",
"items": [
{ "text": "Unified score submission onto a single internal system — previously two parallel systems existed, and only one of them triggered live updates" },
{ "text": "Consolidated duplicate internal data types that had drifted out of sync with each other" }
]
}
],
"examples": []
}

View file

@ -0,0 +1,52 @@
{
"version": "v0.9",
"date": "2026-06-20",
"title": "Leaderboards, Results & Aligned Stats",
"layout": "default",
"sections": [
{
"type": "new",
"label": "New",
"emoji": "✨",
"items": [
{ "text": "Weekly Leaderboard in <:capella:> · <:procyon:> — live rankings updated after every score submission" },
{ "text": "TG Result posts — full breakdown after each TG, with W.Rank, score, K/D, and per-character stats" },
{ "text": "Weekly Highlights — most kills, most deaths, and next Storm/Luminous Bringer predictions" },
{ "text": "/tg call — officers and trusted players can end a TG early when it's called" },
{ "text": "/tg poll confirm-no — officially mark a TG as cancelled, with a clear visual indicator" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "⚙️",
"items": [
{ "text": "Leaderboard and Result layouts now have aligned columns — name, score, K/D and TG count line up cleanly across all players" },
{ "text": "Multiple display layouts available for both Leaderboard and Result — officers can switch between them" },
{ "text": "Secondary combat stats (ATK / DEF / Heal) now shown directly under each player's main stats when recorded" },
{ "text": "W.Rank shown on Result posts now reflects the rank at the moment the score was submitted, not the current rank — more accurate for historical TGs" }
]
},
{
"type": "fix",
"label": "Fixes",
"emoji": "🔧",
"items": [
{ "text": "Fixed a long-standing bug where K/D could silently show as 0/0 even when kills and deaths were recorded" },
{ "text": "Fixed weekly reset occasionally computing the wrong week after a Timezone inconsistency" },
{ "text": "Fixed Bringer assignment not correctly carrying over in some weeks" }
]
},
{
"type": "technical",
"label": "Under the hood",
"emoji": "🛠️",
"items": [
{ "text": "Built a reusable text-alignment system for embeds, using real font metrics extracted directly from Discord's font" },
{ "text": "Persistent message system extended to support multiple independently-updatable embeds in one message" },
{ "text": "Added an event system so different parts of the bot can react to score submissions without being tightly coupled" }
]
}
],
"examples": []
}

View file

@ -1,4 +1,4 @@
{
"latest": "v0.8",
"versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"]
"latest": "v0.9.1",
"versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1"]
}

View file

@ -262,5 +262,17 @@
"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>",
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>"
}

View file

@ -1,18 +0,0 @@
{
"public": {
"yes": [
{ "clicks": 1, "random": true, "messages": ["Dey is in"]},
{ "clicks": 2, "random": true, "messages": ["Courageous now, new account afterall"] },
{ "clicks": 10, "random": true, "messages": ["Now you're just asking for it."] }
],
"no": [
{ "clicks": 1, "random": true, "messages": [
"Everything's for sale",
"Dey roaching out 🪳",
"Dey said no... shocking"
]
}
]
},
"ephemeral": {}
}

View file

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

View file

@ -31,11 +31,13 @@
}
}
Config.load();
const TOKEN = process.env.DISCORD_TOKEN!;
const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
const DONOR_GUILD_IDS = Config.get({ section: "emoji", key: "donorGuilds" });
if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
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);
}
@ -55,6 +57,8 @@
"wrank_up": (f) => `wrank_up_${f}`,
"wrank_down": (f) => `wrank_down_${f}`,
"wrank_x": (f) => `wrank_x_${f}`,
"anima-mastery_stats": (f) => `anima_${f}`,
"circle": (f) => `circle_${f}`,
};
function resolveEmojiName(dirName: string, filename: string): string {

View file

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

View file

@ -6,6 +6,10 @@ import {
} from "@subcommands/admin/userMap";
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";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
export function buildTgAdminCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder()
@ -99,11 +103,95 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
)
)
// ── result group ─────────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("result")
.setDescription("Manage TG results")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post or edit a TG result")
.addStringOption((o) => o.setName("history_key").setDescription("TG to post").setRequired(true).setAutocomplete(true))
)
)
// ── leaderboard group ─────────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("leaderboard")
.setDescription("Manage leaderboard")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post or edit the leaderboard")
.addStringOption((o) => o.setName("week_key").setDescription("Week e.g. 2026-W24 (defaults current)").setAutocomplete(true))
)
.addSubcommand((s) => s
.setName("post-highlights")
.setDescription("Post or edit the leaderboard highlights")
.addStringOption((o) => o.setName("week_key").setDescription("Week e.g. 2026-W24 (defaults current)").setAutocomplete(true))
)
)
cmd.addSubcommand((s) => s
.setName("score-inject")
.setDescription("Inject a score for any player (officer only)")
.addStringOption((o) => o.setName("char_name").setDescription("Character").setRequired(true).setAutocomplete(true))
.addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true))
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true))
.addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)"))
.addIntegerOption((o) => o.setName("k").setDescription("Kills"))
.addIntegerOption((o) => o.setName("d").setDescription("Deaths"))
.addIntegerOption((o) => o.setName("atk").setDescription("ATK damage"))
.addIntegerOption((o) => o.setName("def").setDescription("DEF damage taken"))
.addIntegerOption((o) => o.setName("heal").setDescription("Healing done"))
.addStringOption((o) => o
.setName("played_by")
.setDescription("Userkey of who actually played (if different from character owner)")
)
)
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" },
)
)
)
cmd.addSubcommandGroup((g) => g
.setName("announcement")
.setDescription("Manage announcements")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post an announcement")
.addStringOption((o) => o.setName("id").setDescription("Announcement ID").setRequired(true).setAutocomplete(true))
.addBooleanOption((o) => o.setName("force").setDescription("Repost even if already posted"))
)
.addSubcommand((s) => s
.setName("preview")
.setDescription("Preview an announcement")
.addStringOption((o) => o.setName("id").setDescription("Announcement ID").setRequired(true).setAutocomplete(true))
)
.addSubcommand((s) => s
.setName("list")
.setDescription("List all announcements")
)
)
return cmd;
}
export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise<void> {
const group = interaction.options.getSubcommandGroup(true);
const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand();
if (group === "user") {
@ -122,4 +210,15 @@ 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 === "announcement" && sub === "post") return AnnouncementCommands.post(interaction);
if (group === "announcement" && sub === "preview") return AnnouncementCommands.preview(interaction);
if (group === "announcement" && sub === "list") return AnnouncementCommands.list(interaction);
if (group === null && sub === "test-align") return TestAlignCommands.handle(interaction);
}

View file

@ -4,6 +4,9 @@ import { hasOfficerRole } from "../systems/users";
import { replyAndDelete } from "../utils";
import { 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);
}

24
src/discord/client.ts Normal file
View file

@ -0,0 +1,24 @@
export { Interaction } from "./interaction";
export { Guild } from "./guild";
export { Channel } from "./channel";
// ─── Client registry ──────────────────────────────────────────────────────────
import { Client } from "discord.js";
let _client: Client | null = null;
export function setClient(client: Client): void {
_client = client;
}
export function getClient(): Client {
if (!_client) throw new Error("[Discord] Client not initialized — call setClient() first");
return _client;
}
export const DiscordClient = {
get: getClient,
set: setClient
};

View file

@ -18,8 +18,11 @@ import { Interaction } from "./interaction";
import { Guild } from "./guild";
import { Channel } from "./channel";
// ───────────────────────────────────────────────────────────────────────────────
export const Discord = {
Interaction,
Guild,
Channel,
};

View file

@ -10,7 +10,11 @@ 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";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
// ─── Usermap cache ────────────────────────────────────────────────────────────
let _usermapCache: Record<string, any> | null = null;
@ -42,8 +46,11 @@ async function autocompleteCharNames(
.filter((c) => !nation || c.nation === nation)
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
.map((c) => {
const nationEmoji = c.nation ? (NATION_UNICODE[c.nation] || c.nation) : "";
return { name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), value: c.name };
const classKey = typeof c.class === "object" ? c.class?.key : c.class;
return {
name: `${classKey} ${c.level} ${c.name} [${c.nation}]`.trim(),
value: c.name
};
})
.slice(0, 25);
return interaction.respond(results);
@ -53,18 +60,18 @@ async function autocompleteCharNames(
// Own chars
const ownChars = getCharacters(user.userKey).map((c) => {
const nationEmoji = c.nation ? (Emoji.nation(c.nation) || c.nation) : "";
const classKey = typeof c.class === "object" ? c.class?.key : c.class;
return {
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(),
name: `${classKey} ${c.level} ${c.name} [${c.nation}]`.trim(),
value: c.name,
};
});
// Shared chars
const sharedChars = CharacterRegistry.sharedWith(user.userKey).map(({ char }) => {
const nationEmoji = char.nation ? (Emoji.nation(char.nation) || char.nation) : "";
const classKey = typeof char.class === "object" ? char.class?.key : char.class;
return {
name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
name: `${classKey} ${char.level} ${char.name} [${char.nation}] 🔗`.trim(),
value: char.name,
};
});
@ -129,8 +136,17 @@ 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
}
if (optionName === "id") return await AnnouncementCommands.autocomplete(interaction);
await interaction.respond([]);
} catch (err) {

View file

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

View file

@ -0,0 +1,71 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Announcements } from "@systems/announcements";
import { Discord } from "@discord";
export async function handleAnnouncementPost(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const opts = Discord.Interaction.options(interaction);
const id = opts.string({ key: "id", required: true })!;
const force = opts.boolean({ key: "force" }) ?? false;
const result = await Announcements.post({ id, client: interaction.client, force });
if (!result.ok) {
await Discord.Interaction.editReply(interaction, { content: `${result.reason}` });
return;
}
await Discord.Interaction.editReply(interaction, { content: `✅ Announcement \`${id}\` posted.` });
}
export async function handleAnnouncementPreview(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const opts = Discord.Interaction.options(interaction);
const id = opts.string({ key: "id", required: true })!;
const embed = Announcements.buildEmbed({ id });
if (!embed) {
await Discord.Interaction.editReply(interaction, { content: `❌ Announcement \`${id}\` not found.` });
return;
}
await Discord.Interaction.editReply(interaction, { content: "", embeds: [embed] });
}
export async function handleAnnouncementList(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const ids = Announcements.list();
const lines = ids.map((id) => {
const a = Announcements.get({ id });
const posted = Announcements.isPosted({ id }) ? "✅ posted" : "⬜ not posted";
return `\`${id}\`${a?.title ?? "?"} (${posted})`;
});
await Discord.Interaction.editReply(interaction, {
content: lines.length > 0 ? lines.join("\n") : "No announcements found.",
});
}
export async function autocompleteAnnouncementId(interaction: any): Promise<void> {
console.time("autocomplete-announcement");
const focused = interaction.options.getFocused().toLowerCase();
const choices = Announcements.list()
.filter((id) => id.toLowerCase().includes(focused))
.map((id) => {
const a = Announcements.get({ id });
return { name: `${id}${a?.title ?? ""}`.slice(0, 100), value: id };
})
.slice(0, 25);
console.timeEnd("autocomplete-announcement");
await interaction.respond(choices);
}
export const AnnouncementCommands = {
post: handleAnnouncementPost,
preview: handleAnnouncementPreview,
list: handleAnnouncementList,
autocomplete: autocompleteAnnouncementId,
};

View file

@ -0,0 +1,77 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Result } from "@systems/result";
import { TGKey } from "@systems/tg-key";
import { Discord } from "@discord";
import { Paths } from "@paths";
import fs from "fs";
export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const opts = Discord.Interaction.options(interaction);
const historyKey = opts.string({ key: "history_key" });
if (!historyKey || !TGKey.isValid(historyKey)) {
await Discord.Interaction.editReply(interaction, "❌ Invalid or missing history key.");
return;
}
await Result.post({ historyKey: historyKey as TGKey });
await Discord.Interaction.editReply(interaction, `✅ Result posted for \`${TGKey.toDisplay(historyKey as TGKey)}\`.`);
}
export async function handleLeaderboardPost(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const opts = Discord.Interaction.options(interaction);
const weekKey = opts.string({ key: "week_key" }) ?? undefined;
const { Leaderboard } = require("@systems/leaderboard");
await Leaderboard.update({ weekKey });
await Discord.Interaction.editReply(interaction, `✅ Leaderboard updated${weekKey ? ` for \`${weekKey}\`` : ""}.`);
}
export async function handleLeaderboardPostHighlights(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const opts = Discord.Interaction.options(interaction);
const weekKey = opts.string({ key: "week_key" }) ?? undefined;
const { Leaderboard } = require("@systems/leaderboard");
await Leaderboard.updateHighlights({ weekKey });
await Discord.Interaction.editReply(interaction, `✅ Leaderboard highlights updated${weekKey ? ` for \`${weekKey}\`` : ""}.`);
}
export async function autocompleteWeekKey(interaction: any): Promise<void> {
const focused = interaction.options.getFocused().toLowerCase();
const { WRank } = require("@systems/wrank");
const weeks = Object.keys(WRank.allWeeks())
.filter((k: string) => k.toLowerCase().includes(focused))
.sort().reverse().slice(0, 25)
.map((k: string) => ({ name: k, value: k }));
await interaction.respond(weeks);
}
export async function autocompleteHistoryKey(interaction: any): Promise<void> {
const focused = interaction.options.getFocused().toLowerCase();
const histDir = Paths.data("tg-history");
if (!fs.existsSync(histDir)) { await interaction.respond([]); return; }
const choices = fs.readdirSync(histDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""))
.filter((k) => TGKey.isValid(k) && TGKey.toDisplay(k as TGKey).toLowerCase().includes(focused))
.sort()
.reverse()
.slice(0, 25)
.map((k) => ({
name: TGKey.toDisplay(k as TGKey),
value: k,
}));
await interaction.respond(choices);
}
export const ResultCommands = {
post: handleResultPost,
leaderboardPost: handleLeaderboardPost,
leaderboardHighlights: handleLeaderboardPostHighlights,
autocompleteHistory: autocompleteHistoryKey,
autocompleteWeekKey: autocompleteWeekKey,
};

View file

@ -0,0 +1,65 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config";
import { CharacterRegistry } from "@registry/character-registry";
import { Score } from "@systems/score";
import { TGKey } from "@systems/tg-key";
import { Discord } from "@discord";
import { RuntimeEvents } from "@systems/runtime";
import { hasOfficerRole } from "@systems/users";
export async function handleScoreInject(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
await Discord.Interaction.editReply(interaction, "❌ Officer only.");
return;
}
const opts = Discord.Interaction.options(interaction);
const charName = opts.string({ key: "char_name", required: true })!;
const playedByArg = opts.string({ key: "played_by" }) ?? undefined;
const pts = opts.integer({ key: "pts", required: true })!;
const slot = opts.integer({ key: "slot", required: true })!;
const date = 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 atk = opts.integer({ key: "atk" }) ?? undefined;
const def = opts.integer({ key: "def" }) ?? undefined;
const heal = opts.integer({ key: "heal" }) ?? undefined;
const char = CharacterRegistry.find(charName);
if (!char) {
await Discord.Interaction.editReply(interaction, `❌ Character **${charName}** not found.`);
return;
}
const historyKey = TGKey.from({ date: date, slot });
Score.submit({
character: char,
pts,
k,
d,
atk,
def,
heal,
slot,
date,
playedBy: playedByArg,
submittedByOfficer: true,
});
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character: char });
await Discord.Interaction.editReply(interaction,
`✅ Score injected for **${char.name}** — 📊 ${pts}${k !== undefined ? ` ⚔️ ${k}/${d ?? 0}` : ""} · \`${TGKey.toDisplay(historyKey)}\``
);
}
export const ScoreInjectCommands = {
inject: handleScoreInject,
autocompleteHistory: async (interaction: any) => {
const { autocompleteHistoryKey } = require("./result-post");
await autocompleteHistoryKey(interaction);
},
};

View file

@ -0,0 +1,42 @@
/**
* Temporary calibration tool for TextAlign filler-character widths inside
* EMBEDS specifically (plain messages render differently than embed fields).
* Remove once TextAlign calibration is finalized.
*/
import { ChatInputCommandInteraction, EmbedBuilder } from "discord.js";
import { Discord } from "@discord";
const FILLER_CHARS: Record<string, string> = {
hangul: "", // U+3164 Hangul Filler
thin: "\u2009", // Thin Space
hair: "\u200a", // Hair Space
};
export async function handleTestAlign(interaction: ChatInputCommandInteraction): Promise<void> {
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
const opts = Discord.Interaction.options(interaction);
const nameA = opts.string({ key: "name_a", required: true })!;
const nameB = opts.string({ key: "name_b", required: true })!;
const fillers = opts.integer({ key: "fillers", required: true })!;
const charKey = opts.string({ key: "filler_type" }) ?? "hangul";
const FILLER = FILLER_CHARS[charKey] ?? FILLER_CHARS.hangul;
const paddedA = nameA + FILLER.repeat(fillers);
const embed = new EmbedBuilder()
.setTitle("🧪 Alignment Test (addFields)")
.addFields(
{ name: "\u200b", value: `${paddedA}|`, inline: false },
{ name: "\u200b", value: `${nameB}|`, inline: false },
{ name: "\u200b", value: `\`${nameA}\` (${nameA.length} chars) + ${fillers} \`${charKey}\` fillers vs \`${nameB}\` (${nameB.length} chars)`, inline: false },
)
.setColor(0x5865f2);
await Discord.Interaction.editReply(interaction, { content: "", embeds: [embed] });
}
export const TestAlignCommands = {
handle: handleTestAlign,
};

View file

@ -1,10 +1,8 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
import { polls, updatePollMessage } from "../../systems/poll";
import { replyAndDelete } from "../../utils";
import { Emoji } from "@systems/emojis";
export async function handleCharActive(interaction: ChatInputCommandInteraction): Promise<void> {
const nameArg = interaction.options.getString("name");
@ -22,8 +20,8 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction)
const { char, borrowedFrom } = getEffectiveCharacter(targetKey);
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`);
const { getClassEmoji } = require("../../systems/emojis");
const classEmoji = getClassEmoji(char.class) || char.class;
const classKey = typeof char.class === "object" ? char.class?.key : char.class;
const classEmoji = Emoji.class(classKey) || classKey;
const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
return void replyAndDelete(interaction, `${classEmoji} ${char.level} ${char.name}${borrowed}`);
}

View file

@ -0,0 +1,38 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { hasOfficerRole } from "@systems/users";
import { Discord } from "@discord";
import { replyAndDelete } from "@utils";
export async function handleCall(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const callRoles = Config.get({ section: "roles", key: "callGame" });
if (!hasOfficerRole(member, callRoles)) {
return void replyAndDelete(interaction, "❌ You don't have permission to call TG.", true);
}
const opts = Discord.Interaction.options(interaction);
const slot = opts.integer({ key: "slot", required: true })!;
const state = polls.get(slot);
if (!state) {
return void replyAndDelete(interaction, `❌ No active poll for ${slot}:00.`, true);
}
if (!state.locked) {
return void replyAndDelete(interaction, "❌ Poll must be locked before calling TG.", true);
}
state.called = true;
state.calledAt = new Date().toISOString();
const channel = interaction.channel as TextChannel;
await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ TG at ${slot}:00 has been called.`, true);
}
export const CallCommands = {
call: handleCall,
};

View file

@ -0,0 +1,33 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { hasOfficerRole } from "@systems/users";
import { Discord } from "@discord";
import { replyAndDelete } from "@utils";
export async function handleConfirmNo(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ Officer only.", true);
}
const opts = Discord.Interaction.options(interaction);
const slot = opts.integer({ key: "slot", required: true })!;
const state = polls.get(slot);
if (!state) {
return void replyAndDelete(interaction, `❌ No active poll for ${slot}:00.`, true);
}
state.confirmed = "no";
state.locked = true;
const channel = interaction.channel as TextChannel;
await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ TG at ${slot}:00 confirmed as cancelled.`, true);
}
export const ConfirmNoCommands = {
confirmNo: handleConfirmNo,
};

View file

@ -5,11 +5,7 @@ import { Leaves } from "@systems/leaves";
import { polls, updatePollMessage } from "@systems/poll";
import { CharacterRegistry } from "@registry/character-registry";
import { replyAndDelete } from "@utils";
function getCurrentHistoryKey(slot: number): string {
const date = new Date().toISOString().slice(0, 10);
return `${date}-${slot}`;
}
import { TGKey } from "@systems/tg-key";
export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
@ -27,7 +23,7 @@ export async function handleMarkLeft(interaction: ChatInputCommandInteraction):
const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const historyKey = getCurrentHistoryKey(slot);
const historyKey = TGKey.current({ slot });
Leaves.mark({
characterName: char.name,
@ -59,7 +55,7 @@ export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction)
const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const historyKey = getCurrentHistoryKey(slot);
const historyKey = TGKey.current({ slot });
Leaves.unmark({ characterName: char.name, historyKey });
const channel = interaction.channel as TextChannel;

View file

@ -53,9 +53,9 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
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,
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");

View file

@ -1,12 +1,16 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users";
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
import { Score } from "@systems/score";
import { detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow";
import { replyAndDelete } from "@utils";
import { Emoji } from "@systems/emojis";
import { Discord } from "@discord";
import { User } from "@systems/users";
import { Logger } from "@systems/logger";
import { SlotHour } from "@root/src/types";
const log = Logger.for("score-set");
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
@ -47,19 +51,18 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
}
await submitScore({
userKey: borrowedFrom ?? userKey,
log.debug(`Submitting score: slot=${slot} (type: ${typeof slot}) date=${new Date().toISOString().slice(0,10)}`);
await Score.submit({
character: char,
playedBy: borrowedFrom ? userKey : undefined,
characterName: char.name,
cls: char.class.key,
nation: char.nation,
pts: ptsArg!,
k,
d,
slot,
atk,
def,
heal,
slot: slot as SlotHour,
submittedByOfficer: isOfficer && !!nameArg,
});

View file

@ -2,7 +2,10 @@ import { Config } from "@systems/config";
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow";
import { format } from "@format";
import { getEmoji } from "@systems/emojis";
import { Emoji } from "@systems/emojis";
import { Logger } from "@systems/logger";
const log = Logger.for("score-submit");
// ─── Types ────────────────────────────────────────────────────────────────────
@ -50,6 +53,8 @@ export namespace score {
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
}
log.debug(`Submitting score: slot=${slot} (type: ${typeof slot}) date=${new Date().toISOString().slice(0,10)}`);
await submitScore({
userKey: borrowedFrom ?? userKey,
playedBy: borrowedFrom ? userKey : undefined,
@ -66,9 +71,8 @@ export namespace score {
submittedByOfficer,
});
const scoreEmoji = getEmoji("score") || "📊";
const kdEmoji = getEmoji("kd") || "⚔️";
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = Emoji.get("kd") || "⚔️";
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
const statsNote = [
atk !== undefined ? `ATK: ${atk}` : null,
@ -77,10 +81,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}`,
};
}
}

View file

@ -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<void> {
// discord.js types CommandInteractionOptionResolver with Omit<> which hides
// methods like getString/getInteger at the type level despite them existing at runtime
// Needs to be cast as any, since Discord.js has issues with the type
const options = interaction.options as any;
const member = await interaction.guild!.members.fetch(interaction.user.id);
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<void> {
// discord.js types CommandInteractionOptionResolver with Omit<> which hides
// methods like getString/getInteger at the type level despite them existing at runtime
// Needs to be cast as any, since Discord.js has issues with the type
const options = interaction.options as any;
const 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);
}
export const SetLayoutCommands = {
handle: handleSetLayout,
autocomplete: autocompleteLayout,
};

View file

@ -0,0 +1,38 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config";
import { LeaderboardUI } from "@ui/leaderboard";
import { Discord } from "@discord";
import { hasOfficerRole } from "@systems/users";
export async function handleSetLeaderboardLayout(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
return;
}
const opts = Discord.Interaction.options(interaction);
const name = opts.string({ key: "layout", required: true })!;
if (!LeaderboardUI.setLayout(name)) {
const available = LeaderboardUI.layouts().map((l) => `\`${l.name}\`${l.description}`).join("\n");
await Discord.Interaction.reply(interaction, { content: `❌ Layout \`${name}\` not found.\n${available}`, ephemeral: true });
return;
}
Config.set({ section: "leaderboard", key: "layout", value: name });
await Discord.Interaction.reply(interaction, { content: `✅ Leaderboard layout set to \`${name}\`.`, ephemeral: true });
}
export async function autocompleteLeaderboardLayout(interaction: any): Promise<void> {
const focused = interaction.options.getFocused().toLowerCase();
const choices = LeaderboardUI.layouts()
.filter((l) => l.name.includes(focused))
.map((l) => ({ name: `${l.name}${l.description}`, value: l.name }));
await interaction.respond(choices);
}
export const SetLeaderboardLayoutCommands = {
handle: handleSetLeaderboardLayout,
autocomplete: autocompleteLeaderboardLayout,
};

View file

@ -0,0 +1,38 @@
import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config";
import { ResultUI } from "@ui/result";
import { Discord } from "@discord";
import { hasOfficerRole } from "@systems/users";
export async function handleSetResultLayout(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
return;
}
const opts = Discord.Interaction.options(interaction);
const name = opts.string({ key: "layout", required: true })!;
if (!ResultUI.setLayout(name)) {
const available = ResultUI.layouts().map((l) => `\`${l.name}\`${l.description}`).join("\n");
await Discord.Interaction.reply(interaction, { content: `❌ Layout \`${name}\` not found.\n${available}`, ephemeral: true });
return;
}
Config.set({ section: "result", key: "layout", value: name });
await Discord.Interaction.reply(interaction, { content: `✅ Result layout set to \`${name}\`.`, ephemeral: true });
}
export async function autocompleteResultLayout(interaction: any): Promise<void> {
const focused = interaction.options.getFocused().toLowerCase();
const choices = ResultUI.layouts()
.filter((l) => l.name.includes(focused))
.map((l) => ({ name: `${l.name}${l.description}`, value: l.name }));
await interaction.respond(choices);
}
export const SetResultLayoutCommands = {
handle: handleSetResultLayout,
autocomplete: autocompleteResultLayout,
};

View file

@ -0,0 +1,172 @@
/**
* Announcements one-off celebratory/feature-launch posts.
*
* Unlike Updates (versioned, can be reposted/edited) or PersistentMessage
* (edit-in-place), each Announcement is posted FRESH exactly once and
* stays as its own permanent message in the channel no editing.
*
* Usage:
* import { Announcements } from "@systems/announcements";
*
* Announcements.list()
* Announcements.get({ id: "001-leaderboards-results-live" })
* await Announcements.post({ id: "001-leaderboards-results-live", client })
*/
import fs from "fs";
import path from "path";
import { Client, EmbedBuilder, TextChannel } from "discord.js";
import { Paths } from "@paths";
import { Store } from "@systems/store";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { Logger } from "@systems/logger";
const log = Logger.for("announcements");
// ─── Types ────────────────────────────────────────────────────────────────────
interface AnnouncementItem {
text: string;
emojiKey?: string | null;
}
interface AnnouncementSection {
label: string;
emoji: string;
items: AnnouncementItem[];
}
export interface Announcement {
id: string;
title: string;
date: string;
intro?: string;
sections: AnnouncementSection[];
channelLinks?: { label: string; channelKey: string }[]; // channelKey -> Config.channels.{key}
imageUrl?: string;
color?: string; // hex string, e.g. "#e8a317"
}
// ─── Paths ────────────────────────────────────────────────────────────────────
function announcementsDir(): string {
return Paths.data("announcements");
}
function announcementDir(id: string): string {
return path.join(announcementsDir(), id);
}
// ─── Posted-tracking (so we never accidentally double-post the same one) ────
function postedPath(): string {
return path.join(announcementsDir(), ".posted.json");
}
function getPosted(): Record<string, string> {
return Store.readOrDefault<Record<string, string>>(postedPath(), {});
}
function markPosted(id: string, messageId: string): void {
const posted = getPosted();
posted[id] = messageId;
Store.write(postedPath(), posted);
}
// ─── Embed building ───────────────────────────────────────────────────────────
function resolveChannelLink(channelKey: string): string {
try {
const channelId = Config.get({ section: "channels", key: channelKey as any });
return channelId ? `<#${channelId}>` : `#${channelKey}`;
} catch {
return `#${channelKey}`;
}
}
function buildAnnouncementEmbed(a: Announcement): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(a.title)
.setColor((a.color ?? "#e8a317") as any)
.setTimestamp();
const lines: string[] = [];
if (a.intro) {
lines.push(a.intro, "");
}
for (const section of a.sections) {
if (section.label) lines.push(`${section.emoji} **${section.label}**`);
for (const item of section.items) {
const bullet = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•";
lines.push(`${bullet} ${Emoji.resolveTokens(item.text)}`);
}
lines.push("");
}
if (a.channelLinks?.length) {
const links = a.channelLinks
.map((l) => `${l.label}: ${resolveChannelLink(l.channelKey)}`)
.join("\n");
lines.push(links);
}
embed.setDescription(lines.join("\n").trim());
if (a.imageUrl) embed.setImage(a.imageUrl);
embed.setFooter({ text: a.date });
return embed;
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const Announcements = {
list(): string[] {
const dir = announcementsDir();
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter((f) => fs.statSync(path.join(dir, f)).isDirectory())
.sort();
},
get({ id }: { id: string }): Announcement | null {
return Store.read<Announcement>(path.join(announcementDir(id), "announcement.json"));
},
isPosted({ id }: { id: string }): boolean {
return !!getPosted()[id];
},
buildEmbed({ id }: { id: string }): EmbedBuilder | null {
const a = Announcements.get({ id });
return a ? buildAnnouncementEmbed(a) : null;
},
/**
* Post an announcement. By default refuses to re-post one already
* posted (announcements are meant to be one-off) pass force:true
* to deliberately repost as a NEW message anyway.
*/
async post({ id, client, force = false }: { id: string; client: Client; force?: boolean }): Promise<{ ok: boolean; reason?: string }> {
const a = Announcements.get({ id });
if (!a) return { ok: false, reason: `Announcement "${id}" not found.` };
if (!force && Announcements.isPosted({ id })) {
return { ok: false, reason: `Announcement "${id}" was already posted. Use force to repost anyway.` };
}
const channelId = Config.get({ section: "channels", key: "announcements" });
if (!channelId) return { ok: false, reason: "announcements channel not configured." };
const channel = await client.channels.fetch(channelId) as TextChannel;
const embed = buildAnnouncementEmbed(a);
const msg = await channel.send({ embeds: [embed] });
markPosted(id, msg.id);
log.info(`Posted announcement ${id} (${msg.id})`);
return { ok: true };
},
};

View file

@ -11,11 +11,12 @@
import fs from "fs";
import { Paths } from "@paths";
import { UserKey, HistoryKey } from "@types";
import { UserKey } from "@types";
import { Store } from "@systems/store";
import { TGKey } from "@systems/tg-key";
interface AttendanceData {
[historyKey: HistoryKey]: UserKey[];
[historyKey: TGKey]: UserKey[];
}
let _data: AttendanceData = {};
@ -34,8 +35,7 @@ function save(): void {
* Snapshot attendance from poll state at lock time.
*/
snapshot(slot: number, lockedYesKeys: Set<UserKey>): void {
const date = new Date().toISOString().slice(0, 10);
const historyKey = `${date}-${slot}` as HistoryKey;
const historyKey = TGKey.current({ slot });
_data[historyKey] = [...lockedYesKeys];
save();
},
@ -43,28 +43,30 @@ function save(): void {
/**
* Get players who attended a specific TG.
*/
players(historyKey: HistoryKey): UserKey[] {
players(historyKey: TGKey): UserKey[] {
return _data[historyKey] ?? [];
},
/**
* Check if a specific player attended.
*/
includes(historyKey: HistoryKey, userKey: UserKey): boolean {
includes(historyKey: TGKey, userKey: UserKey): boolean {
return (_data[historyKey] ?? []).includes(userKey);
},
/**
* Check if all attendees have submitted scores.
*/
allSubmitted(historyKey: HistoryKey): boolean {
allSubmitted(historyKey: TGKey): boolean {
const players = _data[historyKey] ?? [];
if (players.length === 0) return false;
try {
const result = JSON.parse(
fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8")
);
const submitted = new Set(result.scores?.map((s: any) => s.userKey) ?? []);
const submitted = new Set(
result.scores?.map((s: any) => s.playedBy ?? s.userKey) ?? []
);
return players.every((p) => submitted.has(p));
} catch {
return false;
@ -74,7 +76,7 @@ function save(): void {
/**
* Get all history keys (for listing past TGs).
*/
all(): HistoryKey[] {
return Object.keys(_data) as HistoryKey[];
all(): TGKey[] {
return Object.keys(_data) as TGKey[];
},
};

View file

@ -13,12 +13,15 @@ interface ChannelConfig {
results: string;
score: string;
updates: string;
leaderboard: string;
announcements: string;
}
interface RoleConfig {
officer: string[];
config: string[];
tag: string[];
callGame: string[];
}
interface PollConfig {
@ -38,9 +41,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;
@ -84,6 +98,8 @@ export interface SectionMap {
channels: ChannelConfig;
roles: RoleConfig;
poll: PollConfig;
result: ResultConfig;
leaderboard: LeaderboardConfig;
wrank: WRankConfig;
bringer: BringerConfig;
impersonate: ImpersonateConfig;
@ -103,12 +119,15 @@ function getDefaults(): SectionMap {
poll: "",
results: "",
score: "",
updates: ""
updates: "",
leaderboard: "",
announcements: ""
},
roles: {
officer: ["Ice King"],
config: ["Ice King"],
tag: ["Ice King", "Ice", "Rebellion"],
callGame: ["Ice King"],
},
poll: {
layout: "default",
@ -129,6 +148,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,

View file

@ -1,6 +1,7 @@
import { Character, CharacterClass, Nation } from "@src/types";
import { Character, CharacterClass, ClassKey, Nation, TGStats } from "@src/types";
import { Emoji } from "@systems/emojis";
import { 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();
@ -130,6 +132,114 @@ function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string {
return `${dash} (${dash}${zero})`; // "— ( — 0 )" when others have delta
}
// ─── Date formatters ────────────────────────────────────────────────────────
function date(date: Date | string, fmt: string = "dd/MM/YYYY"): string {
const d = typeof date === "string" ? new Date(date) : date;
return fmt
.replace("dd", String(d.getDate()).padStart(2, "0"))
.replace("MM", String(d.getMonth() + 1).padStart(2, "0"))
.replace("YYYY", String(d.getFullYear()))
.replace("HH", String(d.getHours()).padStart(2, "0"))
.replace("mm", String(d.getMinutes()).padStart(2, "0"));
}
// ─── Number formatters ────────────────────────────────────────────────────────
function abbrev(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return `${n}`;
}
function colorNumber(n: number, prefix: "wrank_up" | "wrank_down"): string {
if (n <= 200) {
const emoji = Emoji.get(`${prefix}_${n}`);
if (emoji) return emoji;
}
return String(n).split("").map((d) => Emoji.get(`${prefix}_${d}`) || d).join("");
}
// ─── TG / Stats / K/D formatters ────────────────────────────────────────────────────────
function formatTGCount(tgCount: number, tgGoal: number): string {
if (tgCount >= tgGoal) {
const emoji = Emoji.get(`wrank_${tgGoal}_gold`) || `${tgGoal}`;
return `${emoji}${emoji}`;
}
const doneEmoji = Emoji.get(`wrank_${tgCount}`) || `${tgCount}`;
const goalEmoji = Emoji.get(`wrank_${tgGoal}_gold`) || `${tgGoal}`;
return `${doneEmoji}${goalEmoji}`;
}
function formatStats(stats?: TGStats): string {
if (!stats) return "";
const parts: string[] = [];
if (stats.atk) parts.push(`${Emoji.get("atk") || "⚔️"} ${format.number.abbrev(stats.atk)}`);
if (stats.def) parts.push(`${Emoji.get("def") || "🛡️"} ${format.number.abbrev(stats.def)}`);
if (stats.heal) parts.push(`${Emoji.get("heal") || "💚"} ${format.number.abbrev(stats.heal)}`);
return parts.join(" · ");
}
function formatKd(kills: number, deaths: number): string {
const kdEmoji = Emoji.get("kd") || "⚔️";
return `${kdEmoji} ${colorNumber(kills, "wrank_down")}/${colorNumber(deaths, "wrank_up")}`;
}
/**
* Format a single secondary stat with its emoji.
* Returns empty string if value is falsy/undefined.
*/
function statText(emojiKey: string, value: number | undefined, fallback: string = "📊"): string {
if (!value) return "";
const emoji = Emoji.get(emojiKey) || fallback;
return `${emoji} ${format.number.abbrev(value)}`;
}
// ─── Score submission line ────────────────────────────────────────────────────
/**
* Format the score submission confirmation line.
* "<upload> (20:00 TG) WI 79 »Flash« <score> 2000 <kd> 18/4 <atk> X <def> X <heal> X"
*/
function scoreSubmitLine(params: {
slot: number;
char: { class: { key: ClassKey }; level: number; name: string };
pts: number;
k?: number;
d?: number;
atk?: number;
def?: number;
heal?: number;
}): string {
const uploadEmoji = Emoji.get("upload") || "⬆️";
const scoreEmoji = Emoji.get("score") || "📊";
const classStr = Emoji.class(params.char.class.key) || params.char.class.key;
const parts = [
uploadEmoji,
`(${params.slot}:00 TG)`,
classStr,
`${params.char.level}`,
params.char.name,
scoreEmoji,
`${params.pts}`,
];
if (params.k !== undefined || params.d !== undefined) {
parts.push(format.kd(params.k ?? 0, params.d ?? 0));
}
const stats = format.stats(
params.atk || params.def || params.heal
? { atk: params.atk, def: params.def, heal: params.heal }
: undefined
);
if (stats) parts.push(stats);
return parts.join(" ");
}
// ─── Bringer formatters ────────────────────────────────────────────────────────
function bringerDisplay(n: Nation): string {
@ -148,11 +258,49 @@ export const format = {
nation,
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,
};

View file

@ -4,27 +4,33 @@ import { TGResult, TGScore, Nation } from "../types";
import { Nations } from "@systems/nations";
import { Store } from "@systems/store";
import { Paths } from "@helpers/paths";
import { TGKey } from "@systems/tg-key";
import { Logger } from "@systems/logger";
const log = Logger.for("upsert-score");
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history");
function historyKey(date: string, slot: number): string {
return `${date}-${String(slot).padStart(2, "0")}`;
}
function historyPath(key: string): string {
return path.join(HISTORY_DIR, `${key}.json`);
}
export function loadResult(date: string, slot: number): TGResult | null {
return Store.read(historyPath(historyKey(date, slot)));
const key = TGKey.from({ date, slot });
const p = historyPath(key);
log.debug(`loadResult: date=${date} slot=${slot} key=${key} path=${p}`);
return Store.read(p);
}
export function saveResult(result: TGResult): void {
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
Store.write(historyPath(historyKey(result.date, result.slot)), result);
const path = historyPath(TGKey.from({ date: result.date, slot: result.slot }));
Store.write(path, result);
}
export function upsertScore(score: TGScore): void {
const result = loadResult(score.date, score.slot) ?? {
let result = loadResult(score.date, score.slot);
if (!result || !result.date || !result.slot) {
result = {
slot: score.slot,
date: score.date,
confirmed: false,
@ -35,12 +41,14 @@ export function upsertScore(score: TGScore): void {
},
scores: [],
};
}
// Overwrite existing score for this player+slot
result.scores = result.scores.filter(
(s) => !(s.userKey === score.userKey && s.characterName === score.characterName && s.slot === score.slot && s.date === score.date)
);
result.scores.push(score);
log.debug(`upsertScore: about to save — result.date=${result.date} result.slot=${result.slot}`);
saveResult(result);
}

197
src/systems/leaderboard.ts Normal file
View file

@ -0,0 +1,197 @@
/**
* Leaderboard manages weekly W.Rank leaderboard posts.
*
* Usage:
* import { Leaderboard } from "@systems/leaderboard";
*
* await Leaderboard.update({ client }) // called after score submission
* await Leaderboard.post({ client }) // manual post/edit
*/
import { Client, EmbedBuilder } from "discord.js";
import { Nation, Character } from "@types";
import { WRank, WRankEntry, WRankWeek } from "@systems/wrank";
import { Score } from "@systems/score";
import { Leaves } from "@systems/leaves";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { PersistentMessage } from "@systems/persistent-message";
import { Nations } from "@systems/nations";
import { format } from "@format";
import { Logger } from "@systems/logger";
import { TGKey } from "@systems/tg-key";
import { TGStats, WRankPosition } from "@types";
import { Bringer } from "@systems/bringer";
import { DiscordClient } from "@src/discord/client";
import { LeaderboardUI } from "@ui/leaderboard";
import { buildHighlightsEmbed } from "@ui/leaderboard/highlights";
const log = Logger.for("Leaderboard");
PersistentMessage.registerSlot({ store: "leaderboard", slot: "main" });
PersistentMessage.registerSlot({ store: "leaderboard", slot: "highlights" });
// ─── Types ────────────────────────────────────────────────────────────────────
export interface LeaderboardEntry {
character: Character;
weeklyPts: number;
tgCount: number;
totalKills: number;
totalDeaths: number;
stats?: TGStats;
currentRank: number;
previousRank?: number;
leavesCount: number;
}
// ─── Row formatting ───────────────────────────────────────────────────────────
function formatLeaderboardRow(entry: LeaderboardEntry, goal: number): string {
const char = entry.character;
const wrank = format.wrank.rank({ currentRank: entry.currentRank, tgCount: entry.tgCount } as any, goal);
const delta = format.wrank.delta({ currentRank: entry.currentRank, previousRank: entry.previousRank, tgCount: entry.tgCount } as any, { brackets: true });
const classStr = Emoji.class(char.class.key) || char.class.key;
const charStr = `${classStr} ${char.level} ${char.name}`;
const pts = `📊 ${format.number.abbrev(entry.weeklyPts)}`;
const kd = entry.totalKills || entry.totalDeaths ? ` · ${format.kd(entry.totalKills, entry.totalDeaths)}` : "";
const stats = format.stats(entry.stats);
const tgs = format.tgCount(entry.tgCount, goal);
const bringer = Bringer.get({ nation: char.nation }) === char.name
? ` · ${format.bringer(char.nation)}`
: "";
const cockroach = entry.leavesCount > 0
? ` ${Leaves.formatIndicator({ characterName: char.name })}`
: "";
log.info(`char.class:`, char.class, `char.name:`, char.name);
return `${wrank}${delta} ${charStr}${bringer}${cockroach}${pts}${kd}${stats} · ${tgs}`;
}
function buildNationField(
nation: Nation,
entries: LeaderboardEntry[],
goal: number
): string {
if (entries.length === 0) return "—";
// Sort by weeklyPts descending
const sorted = [...entries].sort((a, b) => b.weeklyPts - a.weeklyPts);
return sorted.map((e) => formatLeaderboardRow(e, goal)).join("\n");
}
// ─── Data building ────────────────────────────────────────────────────────────
function buildEntries(nation: Nation, week: WRankWeek): LeaderboardEntry[] {
const wRankEntries = WRank.entriesForNation(nation, week);
return wRankEntries.map((wr): LeaderboardEntry => {
const char = wr.character;
// Aggregate K/D and stats from all active slots this week
let totalKills = 0;
let totalDeaths = 0;
let totalAtk = 0;
let totalDef = 0;
let totalHeal = 0;
for (const historyKey of (week.scoreIndex[char.name] ?? [])) {
const score = Score.get({
character: char,
slot: TGKey.parse(historyKey as TGKey).slot,
historyKey: historyKey as TGKey,
});
if (!score) continue;
totalKills += score.k ?? 0;
totalDeaths += score.d ?? 0;
totalAtk += score.stats?.atk ?? 0;
totalDef += score.stats?.def ?? 0;
totalHeal += score.stats?.heal ?? 0;
}
return {
character: char,
weeklyPts: wr.weeklyPoints,
tgCount: wr.tgCount,
totalKills,
totalDeaths,
stats: {
atk: totalAtk || undefined,
def: totalDef || undefined,
heal: totalHeal || undefined,
},
currentRank: wr.currentRank,
previousRank: wr.previousRank,
leavesCount: Leaves.countForChar({ characterName: char.name }),
};
});
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const Leaderboard = {
/**
* Build and post/edit the leaderboard for the current week.
* Called after each score submission and on weekly reset.
*/
async update({ weekKey }: { weekKey?: string } = {}): Promise<void> {
const client = DiscordClient.get();
const channelId = Config.get({ section: "channels", key: "leaderboard" });
if (!channelId) { log.warn("leaderboard channel not configured"); return; }
const week = weekKey ? WRank.weekFromKey(weekKey) : WRank.currentWeek();
if (!week) { log.warn(`Week ${weekKey} not found`); return; }
const rows = [
...buildEntries(Nation.Capella, week),
...buildEntries(Nation.Procyon, week),
].map((e) => ({
...e,
position: { currentRank: e.currentRank, previousRank: e.previousRank },
}));
const mainEmbed = LeaderboardUI.buildEmbed(week, rows);
await PersistentMessage.updateSlot({
store: "leaderboard",
key: week.weekKey,
slot: "main",
embed: mainEmbed,
channelId,
client,
});
log.info(`Leaderboard main updated for ${week.weekKey}`);
},
async updateHighlights({ weekKey }: { weekKey?: string } = {}): Promise<void> {
const client = DiscordClient.get();
const channelId = Config.get({ section: "channels", key: "leaderboard" });
if (!channelId) { log.warn("leaderboard channel not configured"); return; }
const week = weekKey ? WRank.weekFromKey(weekKey) : WRank.currentWeek();
if (!week) { log.warn(`Week ${weekKey} not found`); return; }
const rows = [
...buildEntries(Nation.Capella, week),
...buildEntries(Nation.Procyon, week),
].map((e) => ({
...e,
position: { currentRank: e.currentRank, previousRank: e.previousRank },
}));
const highlightsEmbed = buildHighlightsEmbed(rows, week.weekKey);
await PersistentMessage.updateSlot({
store: "leaderboard",
key: week.weekKey,
slot: "highlights",
embed: highlightsEmbed,
channelId,
client,
});
log.info(`Leaderboard highlights updated for ${week.weekKey}`);
},
};

View file

@ -15,18 +15,19 @@
* Leaves.formatIndicator({ characterName })
*/
import { UserKey, CharName, HistoryKey } from "@types";
import { UserKey, CharName } from "@types";
import { Store } from "@systems/store";
import { Paths } from "@paths";
import { Emoji } from "@systems/emojis";
import { Runtime } from "@systems/runtime";
import { TGKey } from "@systems/tg-key";
Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" });
// ─── Types ────────────────────────────────────────────────────────────────────
interface LeaveRecord {
historyKey: HistoryKey;
historyKey: TGKey;
markedBy: UserKey;
markedAt: string;
}
@ -60,7 +61,7 @@
mark({ characterName, ownerKey, historyKey, markedBy }: {
characterName: CharName;
ownerKey: UserKey;
historyKey: HistoryKey;
historyKey: TGKey;
markedBy: UserKey;
}): void {
if (!_data[characterName]) {
@ -75,7 +76,7 @@
unmark({ characterName, historyKey }: {
characterName: CharName;
historyKey: HistoryKey;
historyKey: TGKey;
}): void {
if (!_data[characterName]) return;
_data[characterName].history = _data[characterName].history.filter(
@ -87,7 +88,7 @@
hasLeft({ characterName, historyKey }: {
characterName: CharName;
historyKey: HistoryKey;
historyKey: TGKey;
}): boolean {
return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false;
},

View file

@ -0,0 +1,224 @@
/**
* 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 (legacy, unchanged):
* await PersistentMessage.post({ store: "leaderboard", key: "2026-W24", channelId, embeds: [embed], 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,
* });
*/
import fs from "fs";
import path from "path";
import { Client, EmbedBuilder, TextChannel } from "discord.js";
import { Store } from "@systems/store";
import { Paths } from "@paths";
import { Logger } from "@systems/logger";
const log = Logger.for("persistent-message");
// ─── Types ────────────────────────────────────────────────────────────────────
export type MessageStore = "updates" | "leaderboard" | "results";
interface PostParams {
store: MessageStore;
key: string;
channelId: string;
embeds: EmbedBuilder[];
client: Client;
}
interface GetParams {
store: MessageStore;
key: string;
}
interface SetParams {
store: MessageStore;
key: string;
messageId: string;
}
interface DeleteParams {
store: MessageStore;
key: string;
}
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`);
}
function readStore(store: MessageStore): Record<string, string> {
return Store.readOrDefault<Record<string, string>>(storePath(store), {});
}
function writeStore(store: MessageStore, data: Record<string, string>): void {
Store.write(storePath(store), data);
}
// ─── Helpers — slot registry + snapshots ─────────────────────────────────────
const _slotRegistry = new Map<MessageStore, string[]>();
function slotSnapshotPath(store: MessageStore, key: string, slot: string): string {
return Paths.data("snapshots", store, key, `${slot}.json`);
}
function readSlotSnapshot(store: MessageStore, key: string, slot: string): any | null {
return Store.read(slotSnapshotPath(store, key, slot));
}
function writeSlotSnapshot(store: MessageStore, key: string, slot: string, data: any): void {
const filePath = slotSnapshotPath(store, key, slot);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
Store.write(filePath, data);
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const PersistentMessage = {
// ─── Legacy / simple API (unchanged) ─────────────────────────────────────
get({ store, key }: GetParams): string | null {
return readStore(store)[key] ?? null;
},
set({ store, key, messageId }: SetParams): void {
const data = readStore(store);
data[key] = messageId;
writeStore(store, data);
},
delete({ store, key }: DeleteParams): void {
const data = readStore(store);
delete data[key];
writeStore(store, data);
},
async post({ store, key, channelId, embeds, client }: PostParams): Promise<void> {
const channel = await client.channels.fetch(channelId) as TextChannel;
const messageId = PersistentMessage.get({ store, key });
log.debug(`post: store=${store} key=${key} messageId=${messageId}`);
if (messageId) {
try {
const msg = await channel.messages.fetch(messageId);
await msg.edit({ embeds });
log.info(`Edited ${store}/${key} (${messageId})`);
return;
} catch (err: any) {
log.warn(`Could not edit ${messageId}: ${err.message} — posting new`);
PersistentMessage.delete({ store, key });
}
}
const msg = await channel.send({ embeds });
PersistentMessage.set({ store, key, messageId: msg.id });
log.info(`Posted ${store}/${key} (${msg.id})`);
},
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<void> {
const registered = _slotRegistry.get(store) ?? [];
if (!registered.includes(slot)) {
log.warn(`Slot "${slot}" not registered for store "${store}" — registering now`);
PersistentMessage.registerSlot({ store, slot });
}
// Save this slot's new data
const json = embed.toJSON();
writeSlotSnapshot(store, key, slot, json);
// Compose final embeds[] in registration order
const orderedSlots = _slotRegistry.get(store) ?? [slot];
const embeds: EmbedBuilder[] = [];
for (const s of orderedSlots) {
if (s === slot) {
embeds.push(embed);
continue;
}
const snapshot = readSlotSnapshot(store, key, s);
if (snapshot) embeds.push(new EmbedBuilder(snapshot));
// else — slot never populated yet, skip it silently
}
log.debug(`updateSlot: store=${store} key=${key} slot=${slot} totalEmbeds=${embeds.length}`);
await PersistentMessage.post({ store, key, channelId, embeds, client });
},
/**
* Get the last-saved snapshot for a slot, if any.
*/
getSlotSnapshot({ store, key, slot }: { store: MessageStore; key: string; slot: string }): any | null {
return readSlotSnapshot(store, key, slot);
},
};

View file

@ -10,7 +10,6 @@
*/
import fs from "fs";
import path from "path";
import { Character, UserKey, CharName } from "@types";
import { Paths } from "@helpers/paths";
@ -37,11 +36,11 @@
*/
find(charName: CharName): Character | null {
const chars = loadChars();
for (const data of Object.values(chars)) {
for (const [ownerKey, data] of Object.entries(chars)) {
const found = data.characters?.find(
(c) => c.name.toLowerCase() === charName.toLowerCase()
);
if (found) return found;
if (found) return { ...found, ownerKey } as Character;
}
return null;
},
@ -56,9 +55,10 @@
*/
findForUser(userKey: UserKey, charName: CharName): Character | null {
const chars = loadChars();
return chars[userKey]?.characters?.find(
const found = chars[userKey]?.characters?.find(
(c) => c.name.toLowerCase() === charName.toLowerCase()
) ?? null;
);
return found ? { ...found, ownerKey: userKey } as Character : null;
},
/**
@ -66,7 +66,7 @@
*/
forUser(userKey: UserKey): Character[] {
const chars = loadChars();
return chars[userKey]?.characters ?? [];
return (chars[userKey]?.characters ?? []).map((c) => ({ ...c, ownerKey: userKey } as Character));
},
/**
@ -87,7 +87,7 @@
if (ownerKey === userKey) continue;
for (const char of data.characters ?? []) {
if (char.sharedWith?.includes(userKey)) {
result.push({ char, ownerKey });
result.push({ char: { ...char, ownerKey } as Character, ownerKey });
}
}
}

113
src/systems/result.ts Normal file
View file

@ -0,0 +1,113 @@
/**
* Result manages TG result posts to #results channel.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, Character, ClassKey, CLASSES, UserKey, TGScore } from "@types";
import { TGKey } from "@systems/tg-key";
import { Score } 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, ResultRow } from "@ui/result";
const log = Logger.for("result");
// ─── 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.playedBy ?? s.userKey))];
}
}
const weekKey = WRank.weekKey(new Date(date));
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
const rows: ResultRow[] = [];
// Match each ATTENDEE (player) directly against the scores where
// (playedBy ?? userKey) === that player — this is the authoritative
// link, not "does this player own/share a character with a score".
for (const playerKey of players) {
const score = history?.scores.find((s: TGScore) => (s.playedBy ?? s.userKey) === playerKey);
if (!score) continue; // attendee hasn't submitted yet — handled elsewhere (placeholder row)
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: Leaves.countForChar({ characterName: char.name }),
historyKey
});
}
return rows;
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const Result = {
async post({ historyKey }: { historyKey: TGKey }): Promise<void> {
const channelId = Config.get({ section: "channels", key: "results" });
if (!channelId) { log.warn("results channel not configured"); return; }
const client = DiscordClient.get();
const rows = buildRows(historyKey);
log.debug(`Building result for ${historyKey}${rows.length} rows`);
if (rows.length === 0) {
log.warn(`No data for ${historyKey}`);
return;
}
const { date } = TGKey.parse(historyKey);
const weekKey = WRank.weekKey(new Date(date));
const week = WRank.weekFromKey(weekKey);
const embed = ResultUI.buildEmbed(historyKey, rows, week);
await PersistentMessage.post({
store: "results",
key: historyKey,
channelId,
embeds: [embed],
client,
});
log.info(`Result posted for ${historyKey}`);
},
};

View file

@ -173,3 +173,34 @@
}
},
};
// ─── Events ───────────────────────────────────────────────────────────────────
export type RuntimeEvent =
| "scoreSubmitted"
| "pollLocked"
| "pollConfirmed"
| "weekReset"
| "allScoresSubmitted";
type EventHandler<T = any> = (payload: T) => void | Promise<void>;
const _eventHandlers = new Map<RuntimeEvent, EventHandler[]>();
export const RuntimeEvents = {
on<T = any>(event: RuntimeEvent, handler: EventHandler<T>): void {
if (!_eventHandlers.has(event)) _eventHandlers.set(event, []);
_eventHandlers.get(event)!.push(handler as EventHandler);
},
async emit<T = any>(event: RuntimeEvent, payload?: T): Promise<void> {
const handlers = _eventHandlers.get(event) ?? [];
for (const handler of handlers) {
try {
await handler(payload);
} catch (err: any) {
console.error(`[RuntimeEvents] Handler error for ${event}:`, err.message);
}
}
},
};

View file

@ -0,0 +1,29 @@
/**
* Midnight results post any unposted TG results at midnight.
* Handles cases where not all scores were submitted before midnight.
*/
import { Client } from "discord.js";
import { ScheduledJob } from "./types";
export const job: ScheduledJob = {
name: "midnight-results",
cron: "0 0 * * *",
async run(client: Client) {
const { Attendance } = require("@systems/attendance");
const { Result } = require("@systems/result");
const { PersistentMessage } = require("@systems/persistent-message");
const allKeys = Attendance.all();
const today = new Date().toISOString().slice(0, 10);
// Post results for yesterday's TGs that weren't auto-posted
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
for (const historyKey of allKeys) {
if (!historyKey.startsWith(yesterday)) continue;
const existing = PersistentMessage.get({ store: "results", key: historyKey });
if (existing) continue; // already posted
await Result.post({ historyKey });
}
},
};

View file

@ -9,32 +9,32 @@
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
*/
import { Character, Nation, UserKey, HistoryKey, SlotHour } from "@types";
import { Character, Nation, UserKey, SlotHour, TGStats, TGScore } from "@types";
import { WRank } from "@systems/wrank";
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;
playedBy?: UserKey; // if borrowed
characterName: string;
class: string;
nation: Nation;
pts: number;
k?: number;
d?: number;
atk?: number;
def?: number;
heal?: number;
submittedAt: string;
slot: SlotHour;
date: string;
submittedByOfficer: boolean;
wRankAtSubmission?: {
rank: number;
delta: number;
};
}
// export interface TGScore {
// userKey: UserKey;
// playedBy?: UserKey; // if borrowed
// characterName: string;
// class: string;
// nation: Nation;
// pts: number;
// k?: number;
// d?: number;
// stats?: TGStats;
// submittedAt: string;
// slot: SlotHour;
// date: string;
// submittedByOfficer: boolean;
// wRankAtSubmission?: {
// rank: number;
// delta: number;
// };
// }
export interface WeeklySummary {
userKey: UserKey;
@ -48,14 +48,14 @@
previousRank?: number;
}
function getHistoryPath(historyKey: HistoryKey): string {
function getHistoryPath(historyKey: TGKey): string {
return Paths.data("tg-history", `${historyKey}.json`);
}
function loadHistory(historyKey: HistoryKey): { scores: TGScore[] } {
function loadHistory(historyKey: TGKey): { scores: TGScore[] } {
return Store.readOrDefault(getHistoryPath(historyKey), { scores: [] });
}
function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void {
function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
Store.write(getHistoryPath(historyKey), data);
}
@ -63,14 +63,13 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
/**
* Get a score for a character in a specific TG.
*/
get({ character, slot, date }: {
get({ character, slot, historyKey }: {
character: Character;
slot: SlotHour;
date?: string;
historyKey?: TGKey;
}): TGScore | null {
const d = date ?? new Date().toISOString().slice(0, 10);
const historyKey = `${d}-${slot}` as HistoryKey;
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;
@ -82,11 +81,10 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
getWeeklySummary({ character }: { character: Character }): WeeklySummary {
const week = WRank.currentWeek();
const entry = WRank.entry(character.name, character.nation);
const allKeys = Object.keys(week.scoreIndex[character.name] ?? {}) as HistoryKey[];
const scores: TGScore[] = [];
for (const historyKey of (week.scoreIndex[character.name] ?? [])) {
const history = loadHistory(historyKey as HistoryKey);
const history = loadHistory(historyKey as TGKey);
const score = history.scores.find(
(s) => s.userKey === character.ownerKey && s.characterName === character.name
);
@ -114,9 +112,9 @@ function saveHistory(historyKey: HistoryKey, 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, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByOfficer }: {
character: Character;
borrowedFrom?: UserKey;
playedBy?: UserKey;
pts: number;
k?: number;
d?: number;
@ -124,13 +122,13 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
def?: number;
heal?: number;
slot: SlotHour;
date?: string; // ← NEW, optional, defaults to today
submittedByOfficer?: boolean;
}): void {
const date = new Date().toISOString().slice(0, 10);
const historyKey = `${date}-${slot}` as HistoryKey;
}): Promise<void> {
const resolvedDate = date ?? new Date().toISOString().slice(0, 10);
const historyKey = TGKey.from({ date: resolvedDate, slot });
const history = loadHistory(historyKey);
// Snapshot W.Rank before recording score
const existingEntry = WRank.entry(character.name, character.nation);
const wRankAtSubmission = existingEntry ? {
rank: existingEntry.currentRank,
@ -139,34 +137,32 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
const score: TGScore = {
userKey: character.ownerKey,
playedBy: borrowedFrom,
playedBy: playedBy,
characterName: character.name,
class: character.class.key,
nation: character.nation,
pts,
k,
d,
atk,
def,
heal,
stats: atk !== undefined || def !== undefined || heal !== undefined
? { atk, def, heal }
: undefined,
submittedAt: new Date().toISOString(),
slot,
date,
date: resolvedDate,
submittedByOfficer: submittedByOfficer ?? false,
wRankAtSubmission,
};
// Upsert — replace existing score for same character/slot
history.scores = history.scores.filter(
(s) => !(s.userKey === character.ownerKey &&
s.characterName === character.name &&
s.slot === slot &&
s.date === date)
s.date === resolvedDate)
);
history.scores.push(score);
saveHistory(historyKey, history);
// Record in W.Rank
WRank.recordScore(
character.ownerKey,
character.name,
@ -175,5 +171,6 @@ function saveHistory(historyKey: HistoryKey, data: { scores: TGScore[] }): void
pts,
historyKey
);
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character });
},
};

View file

@ -2,6 +2,9 @@ import { TGScore, Nation, ClassKey } from "../types";
import { Config } from "./config";
import { upsertScore, todayString } from "./history";
import { WRank } from "./wrank";
import { TGKey } from "@systems/tg-key";
import { Logger } from "@systems/logger";
const log = Logger.for("system-scores");
// Normalize a slot string to a 24h integer hour
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
@ -71,7 +74,9 @@ export interface ScoreSubmission {
export function submitScore(sub: ScoreSubmission): void {
const date = sub.date ?? todayString();
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`;
log.debug(`sub.slot=${sub.slot} date=${date}`);
const historyKey = TGKey.from({ date, slot: sub.slot });
log.debug(`historyKey=${historyKey}`);
const score: TGScore = {
userKey: sub.userKey,
@ -82,15 +87,16 @@ 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,
submittedByOfficer: sub.submittedByOfficer,
};
log.debug(`score.date=${score.date} score.slot=${score.slot}`);
upsertScore(score);
WRank.recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
}

52
src/systems/tg-key.ts Normal file
View file

@ -0,0 +1,52 @@
/**
* TGKey branded type for TG session identifiers.
* Format: "YYYY-MM-DD-SLOT" e.g. "2026-06-11-20"
*
* Usage:
* import { TGKey } from "@systems/tg-key";
*
* const key = TGKey.current({ slot: 20 })
* const key = TGKey.from({ date: "2026-06-11", slot: 20 })
* TGKey.parse(key) // { date: "2026-06-11", slot: 20 }
* TGKey.toHistoryPath(key) // ".../tg-history/2026-06-11-20.json"
*/
import { Paths } from "@paths";
export type TGKey = string & { readonly __brand: "TGKey" };
export const TGKey = {
from({ date, slot }: { date: Date | string; slot: number }): TGKey {
const d = date instanceof Date ? date.toISOString().slice(0, 10) : date;
const s = String(slot).padStart(2, "0");
return `${d}-${s}` as TGKey;
},
current({ slot }: { slot: number }): TGKey {
return TGKey.from({ date: new Date(), slot });
},
parse(key: TGKey): { date: string; slot: number } {
const parts = key.split("-");
return {
date: parts.slice(0, 3).join("-"), // "YYYY-MM-DD"
slot: parseInt(parts[3], 10),
};
},
toHistoryPath(key: TGKey): string {
return Paths.data("tg-history", `${key}.json`);
},
/** Format for display: "11/06/2026 · 20:00" */
toDisplay(key: TGKey): string {
const { date, slot } = TGKey.parse(key);
const [year, month, day] = date.split("-");
return `${day}/${month}/${year} · ${slot}:00`;
},
/** Check if a string is a valid TGKey */
isValid(key: string): key is TGKey {
return /^\d{4}-\d{2}-\d{2}-\d{1,2}$/.test(key);
},
};

View file

@ -12,13 +12,13 @@
* TG.getWeeklySummary({ character })
*/
import { Nation, Character, UserKey, HistoryKey, SlotHour } from "@types";
import { Nation, Character, UserKey, SlotHour, TGScore } from "@types";
import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer";
import { Score, TGScore, WeeklySummary } from "@systems/score";
import { Score, WeeklySummary } from "@systems/score";
import { Attendance } from "@systems/attendance";
import { Nations } from "@systems/nations";
import { Config } from "@systems/config";
import { TGKey } from "@systems/tg-key";
export const TG = {
// ── Week ──────────────────────────────────────────────────────────────────
@ -66,7 +66,7 @@
// ── Attendance ────────────────────────────────────────────────────────────
getAttendance({ historyKey, nation }: {
historyKey: HistoryKey;
historyKey: TGKey;
nation?: Nation;
}): UserKey[] {
const players = Attendance.players(historyKey);
@ -75,7 +75,7 @@
return players;
},
allSubmitted(historyKey: HistoryKey): boolean {
allSubmitted(historyKey: TGKey): boolean {
return Attendance.allSubmitted(historyKey);
},
@ -86,7 +86,7 @@
slot: SlotHour;
date?: string;
}): TGScore | null {
return Score.get({ character, slot, date });
return Score.get({ character, slot });
},
getWeeklySummary({ character }: { character: Character }): WeeklySummary {

View file

@ -23,6 +23,8 @@
import { Nation, VoteEntry, PollState } from "@types";
import { WRank } from "@systems/wrank";
import { Leaves } from "@systems/leaves";
import { PersistentMessage } from "@systems/persistent-message";
import { TGKey } from "@systems/tg-key";
const log = Logger.for("updates");
@ -78,7 +80,7 @@
}[];
leaves?: {
characterName: string;
historyKey: string;
historyKey: TGKey;
}[];
}
@ -96,19 +98,6 @@
return path.join(updatesDir(), ".message-ids.json");
}
// ─── Message ID helpers ───────────────────────────────────────────────────────
function getMessageId(version: string): string | null {
const ids = Store.readOrDefault<Record<string, string>>(messageIdsPath(), {});
return ids[version] ?? null;
}
function saveMessageId(version: string, messageId: string): void {
const ids = Store.readOrDefault<Record<string, string>>(messageIdsPath(), {});
ids[version] = messageId;
Store.write(messageIdsPath(), ids);
}
// ─── Versions cache ───────────────────────────────────────────────────────────
let _versionsCache: string[] | null = null;
@ -135,7 +124,10 @@
lines.push(`${section.emoji} **${section.label}**`);
for (const item of section.items) {
const emojiStr = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•";
const resolvedText = Emoji.resolveTokens(item.text);
// Only resolve tokens if text contains our <:key:> pattern (no ID)
const resolvedText = item.text.includes("<:") && item.text.includes(":>")
? Emoji.resolveTokens(item.text)
: item.text;
lines.push(`${emojiStr} ${resolvedText}`);
}
lines.push("");
@ -232,7 +224,10 @@
try {
PollUI.setLayout(example.layout);
const state = buildExamplePollState(exampleData);
const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: `🪲 ${example.caption}` });
const exampleEmbed = PollUI.buildEmbed(state, {
overrideLockMsg: `🪲 ${example.caption}`,
historyKey: exampleData.leaves?.[0]?.historyKey
});
exampleEmbed.setTitle(`📋 Example — ${example.caption}`);
embeds.push(exampleEmbed);
} finally {
@ -254,7 +249,7 @@
const channel = await client.channels.fetch(channelId) as TextChannel;
const embeds = Updates.buildEmbeds(entry);
const messageId = getMessageId(version);
const messageId = PersistentMessage.get({ store: "updates", key: version });
log.debug(`post: version=${version} messageId=${messageId} idsPath=${messageIdsPath()}`);
@ -266,11 +261,12 @@
return;
} catch {
log.warn(`Could not edit ${messageId}, posting new`);
PersistentMessage.delete({ store: "updates", key: version });
}
}
const msg = await channel.send({ embeds });
saveMessageId(version, msg.id);
PersistentMessage.set({ store: "updates", key: version, messageId: msg.id });
log.info(`Posted ${version} (${msg.id})`);
},

View file

@ -1,9 +1,10 @@
import { HistoryKey, UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
import { UserKey, CharName, Nation, ClassKey, Character, CLASSES } from "@types";
import { Config } from "@systems/config";
import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations";
import { Store } from "@systems/store";
import { Paths } from "@paths";
import { TGKey } from "@systems/tg-key";
import { Runtime } from "@systems/runtime";
import { Logger } from "@systems/logger";
import { CharacterRegistry } from "@registry/character-registry";
@ -40,7 +41,7 @@ export interface WRankEntry {
export interface WRankWeek {
weekKey: string;
entries: Record<Nation, SerializableWRankEntry[]>;
scoreIndex: Record<CharName, HistoryKey[]>;
scoreIndex: Record<CharName, TGKey[]>;
bringer: {
[Nation.Capella]: string | null;
[Nation.Procyon]: string | null;
@ -138,6 +139,10 @@ export const WRank = {
return _data[weekKey] ?? null;
},
allWeeks(): WRankData {
return _data;
},
// ── Score recording ──────────────────────────────────────────────────────────
recordScore(
@ -146,7 +151,7 @@ export const WRank = {
cls: ClassKey,
nation: Nation,
pts: number,
historyKey: HistoryKey
historyKey: TGKey
): void {
const week = ensureWeek(WRank.weekKey());
const list = week.entries[nation];
@ -187,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 ─────────────────────────────────────────────────────────────────

View file

@ -4,7 +4,6 @@ export type UserKey = string;
export type DiscordId = string;
export type CharName = string;
export type SlotHour = number;
export type HistoryKey = string;
export type VoteType = "yes" | "no";
export type ConfirmType = "yes" | "no";
@ -174,39 +173,36 @@ export interface PollState {
lockedYesKeys?: Set<UserKey>;
lockMessage?: string;
confirmMessage?: string;
called?: boolean;
calledAt?: string;
}
// export interface PollState {
// messageId: string | null;
// slot: number;
// yes: Map<string, VoteEntry>; // userId → VoteEntry
// no: Map<string, VoteEntry>;
// locked: boolean;
// confirmed: "yes" | "no" | null;
// lockMessage?: string;
// confirmMessage?: string;
// lockedYesKeys?: Set<string>; // snapshot of userKeys in yes at lock time
// }
// ─── Scores ──────────────────────────────────────────────────────────────────
export interface TGScore {
userKey: string;
userKey: UserKey;
playedBy?: UserKey; // if borrowed
characterName: string;
class: ClassKey;
nation: Nation; // snapshotted at submission time
class: string;
nation: Nation;
pts: number;
k?: number;
d?: number;
stats?: TGStats;
submittedAt: string;
slot: SlotHour;
date: string;
submittedByOfficer: boolean;
wRankAtSubmission?: {
rank: number;
delta: number;
};
}
export interface TGStats {
atk?: number;
def?: number;
heal?: number;
submittedAt: string; // ISO timestamp
slot: number; // TG hour
date: string; // YYYY-MM-DD
submittedByOfficer: boolean;
playedBy?: string; // userKey of who actually played (if borrowed)
}
// ─── TG Result ───────────────────────────────────────────────────────────────
@ -252,6 +248,11 @@ export interface TGResult {
// activeCharacter: Character | null;
// }
export interface WRankPosition {
currentRank: number;
previousRank?: number;
}
// ─── Bringer ─────────────────────────────────────────────────────────────────
export interface BringerState {

133
src/ui/embed-helpers.ts Normal file
View file

@ -0,0 +1,133 @@
/**
* EmbedHelpers utilities for working within Discord embed limits
* and controlling inline field grid layout.
*
* Usage:
* import { EmbedHelpers } from "@ui/embed-helpers";
*
* EmbedHelpers.chunkRows(rows)
* EmbedHelpers.addNationFields(embed, header, rows, inline)
* EmbedHelpers.addPerPlayerGrid(embed, [{ header, rows }, { header, rows }])
*/
interface EmbedLike {
addFields: (...fields: { name: string; value: string; inline: boolean }[]) => any;
}
// ─── Chunking (for non-inline / single-field cases) ──────────────────────────
function chunkRows(rows: string[], separator: string = "\n", maxLen: number = 1024): string[] {
if (rows.length === 0) return [];
const chunks: string[] = [];
let current = "";
for (const row of rows) {
const candidate = current ? `${current}${separator}${row}` : row;
if (candidate.length > maxLen && current) {
chunks.push(current);
current = row;
} else {
current = candidate;
}
}
if (current) chunks.push(current);
console.log(`[EmbedHelpers] chunkRows: ${rows.length} rows -> ${chunks.length} chunk(s), lengths: ${chunks.map(c => c.length).join(", ")}`);
return chunks;
}
function addNationFields(
embed: EmbedLike,
header: string,
rows: string[],
inline: boolean = false
): void {
const chunks = chunkRows(rows);
if (chunks.length === 0) {
embed.addFields({ name: header, value: "—", inline });
return;
}
chunks.forEach((chunk, i) => {
embed.addFields({
name: i === 0 ? header : "\u200b",
value: chunk,
inline,
});
});
}
// ─── Per-player grid ──────────────────────────────────────────────────────────
/**
* Render two columns as a clean 2-column grid, one player per field row.
* The nation header is embedded as bold text at the top of the first
* player's field (not a separate field) to avoid an extra header-to-content gap.
*/
function addPerPlayerGrid(
embed: EmbedLike,
columns: { header: string; rows: string[] }[]
): void {
if (columns.length !== 2) {
for (const col of columns) {
addNationFields(embed, col.header, col.rows, true);
}
return;
}
const [left, right] = columns;
const maxLen = Math.max(left.rows.length, right.rows.length, 1);
for (let i = 0; i < maxLen; i++) {
const leftRow = left.rows[i];
const rightRow = right.rows[i];
const leftValue = i === 0
? `**${left.header}**\n${leftRow ?? "—"}`
: (leftRow ?? "\u200b");
const rightValue = i === 0
? `**${right.header}**\n${rightRow ?? "—"}`
: (rightRow ?? "\u200b");
embed.addFields(
{ name: "\u200b", value: leftValue, inline: true },
{ name: "\u200b", value: rightValue, inline: true },
{ name: "\u200b", value: "\u200b", inline: true },
);
}
}
// ─── Single-column per-player fields ──────────────────────────────────────────
/**
* Render a single-column list, one player per field (no chunking needed
* each field only holds one row, well under the 1024 char limit regardless
* of padding/content length). Header embedded as bold text in the first
* player's field to avoid an extra header-to-content gap.
*/
function addPerPlayerColumn(
embed: EmbedLike,
header: string,
rows: string[]
): void {
if (rows.length === 0) {
embed.addFields({ name: header, value: "—", inline: false });
return;
}
rows.forEach((row, i) => {
const value = i === 0 ? `**${header}**\n\u200b\n${row}` : row;
embed.addFields({ name: "\u200b", value, inline: false });
});
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const EmbedHelpers = {
chunkRows,
addNationFields,
addPerPlayerGrid,
addPerPlayerColumn,
};

126
src/ui/layout.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* Layout shared domain-aware formatting for all embed types.
* Wraps format.ts functions with business logic (Config, Bringer, Leaves).
*
* Usage:
* import { Layout } from "@ui/layout";
*
* Layout.wrank(entry, goal, context)
* Layout.tgCount(done, goal)
* Layout.kd(k, d)
* Layout.bringer(char)
* Layout.cockroach(char, historyKey)
* Layout.indicators(char, { historyKey })
* Layout.formatRow(template, tokens)
*/
import { Character, Nation } from "@types";
import { WRankEntry, WRankWeek } 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, week?: WRankWeek): string {
const bringer = Bringer.get({ nation: char.nation, week });
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; week?: WRankWeek } = {}): string {
return Layout.bringer(char, opts.week) + Layout.cockroach(char, opts.historyKey);
},
/**
* Format a row from a template string with token replacement.
* Tokens: {wrank} {class} {level} {name} {score} {kd} {tgs} {stats}
* Unknown tokens are left as-is.
* Trailing empty tokens and extra spaces are cleaned up.
*/
formatRow(template: string, tokens: Record<string, string>): string {
return template
.replace(/\{(\w+)\}/g, (_, key) => tokens[key] ?? `{${key}}`)
.replace(/ +/g, " ") // only collapse regular ASCII spaces, not all \s
.trim();
},
/**
* Build nation context from any rows that have a position.
*/
nationContext(rows: { position?: { currentRank: number; previousRank?: number } }[]): NationContext {
return {
nationHasRank: rows.some((r) => r.position && r.position.currentRank !== 0),
nationHasDelta: rows.some((r) => r.position?.previousRank !== undefined),
};
},
/**
* Build WRankEntry shape from a position object for use with format.wrank.row.
*/
wrankEntry(
char: Character,
position?: { currentRank: number; previousRank?: number },
weeklyPoints = 0,
tgCount = 0
): WRankEntry | null {
if (!position || position.currentRank === 0) return null;
return {
character: char,
weeklyPoints,
tgCount,
currentRank: position.currentRank,
previousRank: position.previousRank,
};
},
};

View file

@ -0,0 +1,70 @@
/**
* Leaderboard highlights secondary embed with weekly standout stats.
* Most kills, most deaths, next bringer per nation, etc.
*/
import { EmbedBuilder } from "discord.js";
import { Nation } from "@types";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { LeaderboardRow } from "./index";
function topByKills(rows: LeaderboardRow[]): LeaderboardRow | null {
return [...rows].sort((a, b) => b.totalKills - a.totalKills)[0] ?? null;
}
function topByDeaths(rows: LeaderboardRow[]): LeaderboardRow | null {
return [...rows].sort((a, b) => b.totalDeaths - a.totalDeaths)[0] ?? null;
}
function nextBringerCandidate(rows: LeaderboardRow[], goal: number): LeaderboardRow | null {
// Rank 1 with goal TGs met is eligible — pick the rank-1 player if they qualify
const rank1 = rows.find((r) => r.position?.currentRank === 1);
if (!rank1) return null;
return rank1.tgCount >= goal ? rank1 : null;
}
export function buildHighlightsEmbed(allRows: LeaderboardRow[], weekKey: string): EmbedBuilder {
const goal = Config.get({ section: "wrank", key: "goal" });
const capellaRows = allRows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = allRows.filter((r) => r.character.nation === Nation.Procyon);
const topKillsCapella = topByKills(capellaRows);
const topKillsProcyon = topByKills(procyonRows);
const topDeathsAll = topByDeaths(allRows);
// Storm Bringer -> Procyon, Luminous Bringer -> Capella
const nextLuminousBringer = nextBringerCandidate(capellaRows, goal); // Capella
const nextStormBringer = nextBringerCandidate(procyonRows, goal); // Procyon
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const killEmoji = Emoji.get("wrank_down_1") || "⚔️";
const deathEmoji = Emoji.get("wrank_up_1") || "💀";
const stormEmoji = Emoji.get("storm_bringer") || "⚡";
const luminousEmoji = Emoji.get("luminous_bringer") || "🌟";
const lines: string[] = [];
if (topKillsCapella && topKillsCapella.totalKills > 0) {
lines.push(`${killEmoji} Most Kills (${capellaEmoji}): **${topKillsCapella.character.name}** (${topKillsCapella.totalKills})`);
}
if (topKillsProcyon && topKillsProcyon.totalKills > 0) {
lines.push(`${killEmoji} Most Kills (${procyonEmoji}): **${topKillsProcyon.character.name}** (${topKillsProcyon.totalKills})`);
}
if (topDeathsAll && topDeathsAll.totalDeaths > 0) {
lines.push(`${deathEmoji} Most Deaths: **${topDeathsAll.character.name}** (${topDeathsAll.totalDeaths})`);
}
lines.push(""); // spacer
lines.push(`${luminousEmoji} Next Luminous Bringer (${capellaEmoji}): ${nextLuminousBringer ? `**${nextLuminousBringer.character.name}**` : "—"}`);
lines.push(`${stormEmoji} Next Storm Bringer (${procyonEmoji}): ${nextStormBringer ? `**${nextStormBringer.character.name}**` : "—"}`);
return new EmbedBuilder()
.setTitle("📊 Weekly Highlights")
.setColor(0x5865f2)
.setDescription(lines.join("\n"))
.setFooter({ text: `Highlights · ${weekKey}` });
}

122
src/ui/leaderboard/index.ts Normal file
View file

@ -0,0 +1,122 @@
/**
* 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";
import { TGStats } from "@root/src/types";
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?: TGStats;
position?: { currentRank: number; previousRank?: number };
leavesCount: number;
}
export interface LeaderboardLayout {
name: string;
description: string;
buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder;
formatRow(row: LeaderboardRow, context: any): string;
}
// ─── Registry ─────────────────────────────────────────────────────────────────
const _layouts = new Map<string, LeaderboardLayout>();
let _active: LeaderboardLayout | null = null;
export function registerLeaderboardLayout(layout: LeaderboardLayout): void {
_layouts.set(layout.name, layout);
if (!_active) _active = layout;
}
function isLeaderboardLayout(obj: any): obj is LeaderboardLayout {
return obj?.name && obj?.description &&
typeof obj?.buildEmbed === "function" &&
typeof obj?.formatRow === "function";
}
export function discoverLeaderboardLayouts(): void {
const dir = path.join(__dirname, "layouts");
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir)
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
.sort();
for (const file of files) {
try {
const mod = require(path.join(dir, file));
for (const exported of Object.values(mod)) {
if (isLeaderboardLayout(exported)) {
registerLeaderboardLayout(exported as LeaderboardLayout);
log.info(`Registered leaderboard layout: ${(exported as LeaderboardLayout).name}`);
}
}
} catch (err: any) {
log.error(`Failed to load leaderboard layout ${file}: ${err.message}`);
}
}
}
discoverLeaderboardLayouts();
export function restoreLeaderboardLayout(): void {
const saved = Config.get({ section: "leaderboard", key: "layout" });
if (saved && _layouts.has(saved)) {
_active = _layouts.get(saved)!;
log.info(`Restored leaderboard layout: ${saved}`);
}
}
// ─── Helpers ─────────────────────────────────────────────────────
function activeLayout(): LeaderboardLayout {
const layout = _active ?? _layouts.values().next().value;
if (!layout) throw new Error("[LeaderboardUI] No layouts registered");
return layout;
}
export const LeaderboardUI = {
buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
return activeLayout().buildEmbed(week, rows);
},
formatRow(row: LeaderboardRow, context: any): string {
return activeLayout().formatRow(row, context);
},
setLayout(name: string): boolean {
const layout = _layouts.get(name);
if (!layout) return false;
_active = layout;
return true;
},
layouts(): { name: string; description: string }[] {
return [..._layouts.values()].map((l) => ({ name: l.name, description: l.description }));
},
register: registerLeaderboardLayout,
};

View file

@ -0,0 +1,86 @@
/**
* Default leaderboard layout full detail (Option A).
* W.Rank + delta, pts, k/d, TG count per row.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{wrank} {class} {level} {name}{indicators} — {score}{kd} · {tgFlag}{tgs}";
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const tokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
level: `${char.level}`,
name: char.name,
indicators: Layout.indicators(char as any),
score: `${Emoji.get("score") || "📊"} ${format.scoreBold(row.weeklyPts)}`,
kd: Layout.kd(row.totalKills, row.totalDeaths),
tgFlag: Emoji.get("tg_flag") || "🏁",
tgs: Layout.tgCount(row.tgCount, goal),
};
return Layout.formatRow(TEMPLATE, tokens);
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const goal = Config.get({ section: "wrank", key: "goal" });
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
// Sort by weeklyPts descending
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capK = capellaRows.reduce((s, r) => s + r.totalKills, 0);
const capD = capellaRows.reduce((s, r) => s + r.totalDeaths, 0);
const proK = procyonRows.reduce((s, r) => s + r.totalKills, 0);
const proD = procyonRows.reduce((s, r) => s + r.totalDeaths, 0);
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
// .setTitle(`⚔️ W.Rank — ${week.weekKey} · Goal: ${goal} TGs`)
.setColor(0xe8a317)
.addFields(
{
name: `${capellaEmoji} Capella${(capK || capD) ? `${format.kd(capK, capD)}` : ""}`,
value: [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext)).join("\n") || "—",
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon${(proK || proD) ? `${format.kd(proK, proD)}` : ""}`,
value: [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext)).join("\n") || "—",
inline: false,
},
)
.setFooter({ text: `W.Rank · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
return embed;
}
export const defaultLeaderboardLayout: LeaderboardLayout = {
name: "default",
description: "Full detail — W.Rank, pts, K/D, TG count",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,67 @@
/**
* Combined leaderboard layout single ranked list across both nations,
* with nation indicator per row. No delta, rank only (gold when goal met).
*/
import { EmbedBuilder } from "discord.js";
import { ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{rank} {nation} {class} {name}{indicators} — {score}{kd} · [{tgs}]";
function formatRankOnly(currentRank: number, goalMet: boolean): string {
if (!currentRank || currentRank === 0) return "—";
const suffix = goalMet ? "_gold" : "";
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
}
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const goalMet = row.tgCount >= goal;
const scoreEmoji = Emoji.get("score") || "📊";
const tokens: Record<string, string> = {
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
nation: Emoji.nation(char.nation),
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
score: `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`,
kd: Layout.kd(row.totalKills, row.totalDeaths),
tgs: Layout.tgCount(row.tgCount, goal),
};
return Layout.formatRow(TEMPLATE, tokens);
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const context = Layout.nationContext(rows);
const sorted = [...rows].sort(sortByPts);
const formatted = sorted.map((r) => formatRow(r, context));
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
EmbedHelpers.addNationFields(embed, "\u200b", formatted, false);
return embed;
}
export const combinedLeaderboardLayout: LeaderboardLayout = {
name: "combined",
description: "Single ranked list across both nations, rank only (no delta)",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,155 @@
/**
* 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,
allNameBlocks: string[],
allScores: string[],
allKds: string[],
allTgs: string[],
allAtks: string[],
allDefs: string[],
allHeals: string[],
week: WRankWeek
): 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 bringerTag = Layout.bringer(char as any, week);
const nameBlock = bringerTag ? `${char.name}${TextAlign.gap(1)}${bringerTag}` : char.name;
const paddedBlock = TextAlign.padToMax(nameBlock, allNameBlocks);
const cockroach = Layout.cockroach(char as any);
const tokens: Record<string, string> = {
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
class: Emoji.class(classKey) || classKey || "?",
name: paddedBlock,
indicators: cockroach,
score: `${scoreEmoji} ${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("circle_massheal_purple", 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 capellaNameBlocks = sortedCapella.map((r) => {
const tag = Layout.bringer(r.character as any, week);
return tag ? `${r.character.name}${TextAlign.gap(1)}${tag}` : r.character.name;
});
const procyonNameBlocks = sortedProcyon.map((r) => {
const tag = Layout.bringer(r.character as any, week);
return tag ? `${r.character.name}${TextAlign.gap(1)}${tag}` : 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, capellaNameBlocks, capellaScores, capellaKds, capellaTgs, capellaAtks, capellaDefs, capellaHeals, week));
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNameBlocks, procyonScores, procyonKds, procyonTgs, procyonAtks, procyonDefs, procyonHeals, week));
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
EmbedHelpers.addPerPlayerColumn(embed, `${capellaEmoji} Capella`, capellaFormatted);
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
EmbedHelpers.addPerPlayerColumn(embed, `${procyonEmoji} Procyon`, procyonFormatted);
return embed;
}
export const sequentialExtraStatsLeaderboardLayout: LeaderboardLayout = {
name: "sequential-extra-stats",
description: "Same as sequential, plus weekly atk/def/heal on indented second line",
buildEmbed,
formatRow: formatRow as any,
};

View file

@ -0,0 +1,79 @@
/**
* Sequential-stacked leaderboard layout Capella's full list then Procyon's,
* each player using 3 stacked lines (name / score+tg / kd), no delta.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{rank} {class} {name}{indicators}";
function formatRankOnly(currentRank: number, goalMet: boolean): string {
if (!currentRank || currentRank === 0) return "—";
const suffix = goalMet ? "_gold" : "";
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
}
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const goalMet = row.tgCount >= goal;
const mainTokens: Record<string, string> = {
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
};
const scoreEmoji = Emoji.get("score") || "📊";
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)} · [${Layout.tgCount(row.tgCount, goal)}]`;
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
const lines = [mainLine, `\u3000${scoreLine}`];
if (kdLine) lines.push(`\u3000${kdLine}`);
return lines.join("\n");
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
EmbedHelpers.addNationFields(embed, `${capellaEmoji} Capella`, capellaFormatted, false);
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
EmbedHelpers.addNationFields(embed, `${procyonEmoji} Procyon`, procyonFormatted, false);
return embed;
}
export const sequentialStackedLeaderboardLayout: LeaderboardLayout = {
name: "sequential-stacked",
description: "Capella then Procyon, 3-line stacked rows per player, no delta",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,105 @@
/**
* Sequential leaderboard layout Capella's full ranked list first,
* then Procyon's full ranked list below. No delta, rank only.
* Names AND scores padded with invisible filler characters for
* approximate column alignment, computed PER NATION.
*
* NOTE: "—" and "·" separators kept for now as visual markers while
* tuning alignment once padding is solid, these can likely be
* removed since padding alone will provide visual column separation.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { TextAlign } from "@ui/text-align";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{rank} {class} {name}{indicators} {score} {kd} {tgs}";
function formatRankOnly(currentRank: number, goalMet: boolean): string {
if (!currentRank || currentRank === 0) return "—";
const suffix = goalMet ? "_gold" : "";
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
}
function formatRow(
row: LeaderboardRow,
context: NationContext,
allNames: string[],
allScores: string[],
allKds: string[],
allTgs: string[]
): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const goalMet = row.tgCount >= goal;
const scoreEmoji = Emoji.get("score") || "📊";
const scoreText = format.scoreBold(row.weeklyPts);
const kdText = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "—";
const tgText = `[${Layout.tgCount(row.tgCount, goal)}]`;
const tokens: Record<string, string> = {
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
class: Emoji.class(classKey) || classKey || "?",
name: TextAlign.padToMax(char.name, allNames),
indicators: Layout.indicators(char as any),
score: `${scoreEmoji} ${TextAlign.padToMax(scoreText, allScores)}`,
kd: TextAlign.gap(7) + TextAlign.padToMax(kdText, allKds),
tgs: TextAlign.gap(7) + TextAlign.padLeftToMax(tgText, allTgs),
};
return Layout.formatRow(TEMPLATE, tokens);
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const sortedCapella = [...capellaRows].sort(sortByPts);
const sortedProcyon = [...procyonRows].sort(sortByPts);
const capellaNames = sortedCapella.map((r) => r.character.name);
const procyonNames = sortedProcyon.map((r) => r.character.name);
const capellaScores = sortedCapella.map((r) => format.scoreBold(r.weeklyPts));
const procyonScores = sortedProcyon.map((r) => format.scoreBold(r.weeklyPts));
const capellaKds = sortedCapella.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
const procyonKds = sortedProcyon.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
const capellaTgs = sortedCapella.map((r) => `[${Layout.tgCount(r.tgCount, Config.get({ section: "wrank", key: "goal" }))}]`);
const procyonTgs = sortedProcyon.map((r) => `[${Layout.tgCount(r.tgCount, Config.get({ section: "wrank", key: "goal" }))}]`);
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const capellaFormatted = sortedCapella.map((r) => formatRow(r, capContext, capellaNames, capellaScores, capellaKds, capellaTgs));
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNames, procyonScores, procyonKds, procyonTgs));
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
EmbedHelpers.addPerPlayerColumn(embed, `${capellaEmoji} Capella`, capellaFormatted);
embed.addFields({ name: "\u200b", value: "\u200b", inline: false });
EmbedHelpers.addPerPlayerColumn(embed, `${procyonEmoji} Procyon`, procyonFormatted);
return embed;
}
export const sequentialLeaderboardLayout: LeaderboardLayout = {
name: "sequential",
description: "Capella then Procyon, rank only, names+scores+kd padded per-nation",
buildEmbed,
formatRow: formatRow as any,
};

View file

@ -0,0 +1,116 @@
/**
* Side-by-side-sequential leaderboard layout nations rendered side by
* side in a 2-column grid. Due to Discord's narrower column width in
* this layout, content wraps to 2 lines per player:
* Line 1: rank + class + name + indicators + TG count
* Line 2: score + K/D (indented)
* Uses the same TextAlign column-alignment technique as "sequential".
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { TextAlign } from "@ui/text-align";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const LINE1_TEMPLATE = "{rank} {class} {name}{indicators} {tgs}";
const LINE2_TEMPLATE = "{score} {kd}";
// Adjust to tune spacing between columns within each line.
const TGS_GAP = 5;
const KD_GAP = 3;
function formatRankOnly(currentRank: number, goalMet: boolean): string {
if (!currentRank || currentRank === 0) return "—";
const suffix = goalMet ? "_gold" : "";
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
}
function formatRow(
row: LeaderboardRow,
context: NationContext,
allNames: string[],
allScores: string[],
allKds: string[],
allTgs: string[]
): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const goalMet = row.tgCount >= goal;
const scoreEmoji = Emoji.get("score") || "📊";
const scoreText = format.scoreBold(row.weeklyPts);
const kdText = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "—";
const tgText = `[${Layout.tgCount(row.tgCount, goal)}]`;
const line1Tokens: Record<string, string> = {
rank: row.position ? formatRankOnly(row.position.currentRank, goalMet) : "—",
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
tgs: TextAlign.gap(TGS_GAP) + TextAlign.padLeftToMax(tgText, allTgs),
};
const line2Tokens: Record<string, string> = {
score: `${scoreEmoji} ${TextAlign.padToMax(scoreText, allScores)}`,
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, allKds),
};
const line1 = Layout.formatRow(LINE1_TEMPLATE, line1Tokens);
const line2 = Layout.formatRow(LINE2_TEMPLATE, line2Tokens);
return `${line1}\n\u3000${line2}`;
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const sortedCapella = [...capellaRows].sort(sortByPts);
const sortedProcyon = [...procyonRows].sort(sortByPts);
const capellaNames = sortedCapella.map((r) => r.character.name);
const procyonNames = sortedProcyon.map((r) => r.character.name);
const capellaScores = sortedCapella.map((r) => format.scoreBold(r.weeklyPts));
const procyonScores = sortedProcyon.map((r) => format.scoreBold(r.weeklyPts));
const capellaKds = sortedCapella.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
const procyonKds = sortedProcyon.map((r) => (r.totalKills || r.totalDeaths) ? format.kd(r.totalKills, r.totalDeaths) : "—");
const goal = Config.get({ section: "wrank", key: "goal" });
const capellaTgs = sortedCapella.map((r) => `[${Layout.tgCount(r.tgCount, goal)}]`);
const procyonTgs = sortedProcyon.map((r) => `[${Layout.tgCount(r.tgCount, goal)}]`);
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const capellaFormatted = sortedCapella.map((r) => formatRow(r, capContext, capellaNames, capellaScores, capellaKds, capellaTgs));
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNames, procyonScores, procyonKds, procyonTgs));
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const sideBySideSequentialLeaderboardLayout: LeaderboardLayout = {
name: "side-by-side-sequential",
description: "Nations side by side — name+TG count on line 1, score+kd on line 2",
buildEmbed,
formatRow: formatRow as any,
};

View file

@ -0,0 +1,87 @@
/**
* Side-by-side-stacked leaderboard layout same grid as side-by-side,
* but K/D moves to its own line below score for a cleaner per-row look.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{wrank} {class} {name}{indicators}";
const STATS_TEMPLATE = "{score}";
const KD_TEMPLATE = "{kd}";
const TG_TEMPLATE = "[{tgs}]";
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const mainTokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
};
const scoreEmoji = Emoji.get("score") || "📊";
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`;
const kdLine = (row.totalKills || row.totalDeaths)
? format.kd(row.totalKills, row.totalDeaths)
: "";
const tgLine = `[${Layout.tgCount(row.tgCount, goal)}]`;
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
// Three separate lines: name, score, kd+tg
const lines = [mainLine, `\u3000${scoreLine}`];
if (kdLine) lines.push(`\u3000${kdLine} · ${tgLine}`);
else lines[1] += ` · ${tgLine}`; // no K/D — keep score+tg together
return lines.join("\n");
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const goal = Config.get({ section: "wrank", key: "goal" });
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const sideBySideStackedLeaderboardLayout: LeaderboardLayout = {
name: "side-by-side-stacked",
description: "Compact grid with K/D on its own line below score",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,91 @@
/**
* Side-by-side leaderboard layout nations inline (Option C).
* Compact char name + pts + TG count only.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { LeaderboardLayout, LeaderboardRow } from "../index";
import { EmbedHelpers } from "@ui/embed-helpers";
const TEMPLATE = "{wrank} {class} {name}{indicators}";
const STATS_TEMPLATE = "{score}{kd} · [{tgs}]";
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const mainTokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
};
const statsTokens: Record<string, string> = {
score: `${Emoji.get("score") || "📊"} ${format.scoreBold(row.weeklyPts)}`,
kd: Layout.kd(row.totalKills, row.totalDeaths),
tgs: Layout.tgCount(row.tgCount, goal),
};
return `${Layout.formatRow(TEMPLATE, mainTokens)}\n\u3000${Layout.formatRow(STATS_TEMPLATE, statsTokens)}`;
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const goal = Config.get({ section: "wrank", key: "goal" });
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const maxPlayers = Math.max(capellaRows.length, procyonRows.length);
const useInline = maxPlayers <= 5;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
// .setTitle(`⚔️ W.Rank — ${week.weekKey} · Goal: ${goal} TGs`)
.setColor(0xe8a317)
// .addFields(
// {
// name: `${capellaEmoji} Capella`,
// value: [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext)).join("\n\n") || "—",
// inline: useInline,
// },
// {
// name: `${procyonEmoji} Procyon`,
// value: [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext)).join("\n\n") || "—",
// inline: useInline,
// },
// )
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const sideBySideLeaderboardLayout: LeaderboardLayout = {
name: "side-by-side",
description: "Nations inline, compact — char name, pts, TG count",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,75 @@
/**
* Stacked leaderboard layout Option 2: TG count gets its own line at the bottom.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{wrank} {class} {name}{indicators}";
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const mainTokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
};
const scoreEmoji = Emoji.get("score") || "📊";
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`;
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
const tgLine = `[${Layout.tgCount(row.tgCount, goal)}]`;
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
const lines = [mainLine, `\u3000${scoreLine}`];
if (kdLine) lines.push(`\u3000${kdLine}`);
lines.push(`\u3000${tgLine}`);
return lines.join("\n");
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const stackedTgBottomLeaderboardLayout: LeaderboardLayout = {
name: "stacked-tg-bottom",
description: "TG count on its own line at the bottom",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,73 @@
/**
* Stacked leaderboard layout Option 3: TG count joins the score line.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{wrank} {class} {name}{indicators}";
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const mainTokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
};
const scoreEmoji = Emoji.get("score") || "📊";
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)} · [${Layout.tgCount(row.tgCount, goal)}]`;
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
const lines = [mainLine, `\u3000${scoreLine}`];
if (kdLine) lines.push(`\u3000${kdLine}`);
return lines.join("\n");
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const stackedTgWithScoreLeaderboardLayout: LeaderboardLayout = {
name: "stacked-tg-with-score",
description: "TG count joins the score line, K/D below",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,74 @@
/**
* Stacked leaderboard layout Option 1: TG count joins the name line.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{wrank} {class} {name}{indicators} · [{tgs}]";
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.weeklyPts, row.tgCount);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const mainTokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
tgs: Layout.tgCount(row.tgCount, goal),
};
const scoreEmoji = Emoji.get("score") || "📊";
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)}`;
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
const lines = [mainLine, `\u3000${scoreLine}`];
if (kdLine) lines.push(`\u3000${kdLine}`);
return lines.join("\n");
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const stackedTgTopLeaderboardLayout: LeaderboardLayout = {
name: "stacked-tg-top",
description: "TG count joins the name line, score and K/D each on their own line",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,85 @@
/**
* Stacked-with-rank leaderboard layout same as stacked-tg-with-score,
* but shows W.Rank position (no delta) prefixed on the name line.
* Rank number turns gold when goal TGs are met, same coloring as TG count.
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { WRankWeek } from "@systems/wrank";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { EmbedHelpers } from "@ui/embed-helpers";
import { LeaderboardLayout, LeaderboardRow } from "../index";
const TEMPLATE = "{rank} {class} {name}{indicators}";
function formatRankOnly(currentRank: number, goalMet: boolean): string {
if (currentRank === 0) return "";
const suffix = goalMet ? "_gold" : "";
return Emoji.get(`wrank_${currentRank}${suffix}`) || `${currentRank}`;
}
function formatRow(row: LeaderboardRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const goalMet = row.tgCount >= goal;
const rank = row.position && row.position.currentRank !== 0
? formatRankOnly(row.position.currentRank, goalMet)
: (context.nationHasRank ? (Emoji.get("wrank_no_dash") || "—") : "");
const mainTokens: Record<string, string> = {
rank: rank,
class: Emoji.class(classKey) || classKey || "?",
name: char.name,
indicators: Layout.indicators(char as any),
};
const scoreEmoji = Emoji.get("score") || "📊";
const scoreLine = `${scoreEmoji} ${format.scoreBold(row.weeklyPts)} · [${Layout.tgCount(row.tgCount, goal)}]`;
const kdLine = (row.totalKills || row.totalDeaths) ? format.kd(row.totalKills, row.totalDeaths) : "";
const mainLine = Layout.formatRow(TEMPLATE, mainTokens);
const lines = [mainLine, `\u3000${scoreLine}`];
if (kdLine) lines.push(`\u3000${kdLine}`);
return lines.join("\n");
}
function buildEmbed(week: WRankWeek, rows: LeaderboardRow[]): EmbedBuilder {
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const sortByPts = (a: LeaderboardRow, b: LeaderboardRow) => b.weeklyPts - a.weeklyPts;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(`🏆 Weekly Leaderboard — ${week.weekKey}`)
.setColor(0xe8a317)
.setFooter({ text: `Weekly Leaderboard · ${week.weekKey} · Updated ${format.date(new Date(), "dd/MM/YYYY HH:mm")}` })
.setTimestamp();
const capellaFormatted = [...capellaRows].sort(sortByPts).map((r) => formatRow(r, capContext));
const procyonFormatted = [...procyonRows].sort(sortByPts).map((r) => formatRow(r, proContext));
EmbedHelpers.addPerPlayerGrid(embed, [
{ header: `${capellaEmoji} Capella`, rows: capellaFormatted },
{ header: `${procyonEmoji} Procyon`, rows: procyonFormatted },
]);
return embed;
}
export const stackedWithRankLeaderboardLayout: LeaderboardLayout = {
name: "stacked-with-rank",
description: "Same as stacked-tg-with-score but shows W.Rank position (no delta)",
buildEmbed,
formatRow,
};

View file

@ -2,40 +2,24 @@
* BaseLayout shared poll layout functions.
* All layouts inherit these via BaseLayout.methods() spread.
* Override only what differs in each layout.
*
* Usage:
* export const myLayout: PollLayout = {
* ...BaseLayout.methods(),
* name: "my-layout",
* description: "...",
* buildEmbed(state, options) { ... }, // override
* };
*/
import { VoteEntry, Nation } from "@types";
import { EmbedBuilder } from "discord.js";
import { VoteEntry, Nation, PollState } from "@types";
import { Config } from "@systems/config";
import { WRank, WRankEntry } from "@systems/wrank";
import { Bringer } from "@systems/bringer";
import { Leaves } from "@systems/leaves";
import { Emoji } from "@systems/emojis";
import { TGKey } from "@systems/tg-key";
import { format } from "@format";
import { PollRowContext } from "@ui/types";
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
// ─── 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 ───────────────────────────────────────────────────────────
@ -76,8 +60,8 @@
// Nation emoji prefix
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
// Cockroach indicator (left TG)
if (entry.userKey && entry.characterName && context.historyKey) {
// Cockroach indicator
if (entry.characterName && context.historyKey) {
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
}
@ -91,7 +75,7 @@
export function buildContext(
entries: VoteEntry[],
nation: Nation,
options?: { showNationEmoji?: boolean; historyKey?: string }
options?: { showNationEmoji?: boolean; historyKey?: TGKey }
): PollRowContext {
const nationHasRank = entries.some((e) => {
if (!e.characterName) return false;
@ -113,7 +97,25 @@
};
}
// ─── Message formatting ───────────────────────────────────────────────────────
// ─── Shared embed helpers ─────────────────────────────────────────────────────
export function formatNationField(
nation: Nation,
yesEntries: VoteEntry[],
noVoters: VoteEntry[],
showNoInline: boolean,
historyKey?: TGKey
): 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") : "—";
}
export function formatMessages(
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
@ -129,34 +131,16 @@
.join("\n");
}
// ─── Embed helpers ────────────────────────────────────────────────────────────
export 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") : "—";
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 resolveColor(state: any): number {
if (state.confirmed === "yes") return 0x57f287;
if (state.confirmed === "no") return 0xed4245;
if (state.locked) return 0x888888;
return 0xe8a317;
}
export function resolveTitle(state: any, yesByNation: Record<Nation, VoteEntry[]>): string {
export function resolveTitle(state: PollState, yesByNation: Record<Nation, VoteEntry[]>): string {
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const counts = !state.locked && state.confirmed === null
@ -165,18 +149,21 @@
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: any, noCount: number, overrideLockMsg?: string): string {
export function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
if (state.confirmed === "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: any): {
export function buildYesByNation(state: PollState): {
yesByNation: Record<Nation, VoteEntry[]>;
noVoters: VoteEntry[];
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[];
@ -189,10 +176,7 @@
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
for (const entry of state.yes.values()) {
const nation: Nation = entry.characterNation === Nation.Procyon
? Nation.Procyon
: Nation.Capella;
const nation: Nation = (entry.characterNation as Nation) ?? Nation.Capella;
yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" });
}
@ -204,13 +188,84 @@
return { yesByNation, noVoters, allMessages };
}
// ─── buildEmbed factory ───────────────────────────────────────────────────────
export function createBuildEmbed({ inline, maxInlinePlayers = 5 }: {
inline: boolean;
maxInlinePlayers?: number;
}) {
return function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
const historyKey = options?.historyKey
?? TGKey.current({ slot: state.slot });
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
// Auto-stack to vertical if too many players
const maxPlayers = Math.max(
yesByNation[Nation.Capella].length,
yesByNation[Nation.Procyon].length
);
const useInline = inline && maxPlayers <= maxInlinePlayers;
const embed = new EmbedBuilder()
.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
embed.addFields(
{
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,
},
);
} else {
// Vertical — spacer between nations
embed.addFields(
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: false,
},
);
}
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;
};
}
// ─── BaseLayout factory ───────────────────────────────────────────────────────
export const BaseLayout = {
methods() {
return {
formatRow,
buildContext,
};
return { formatRow, buildContext };
},
};

View file

@ -1,212 +0,0 @@
/**
* Default poll layout vertical, nation-separated fields.
* This is the standard layout and always the fallback.
*/
import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation } from "@types";
import { WRankEntry } from "@systems/wrank";
import { Config } from "@systems/config";
import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer";
import { Emoji } from "@systems/emojis";
import { format } from "@format";
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
import { Leaves } from "@systems/leaves";
// ─── Row formatting ───────────────────────────────────────────────────────────
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
if (!wRankEntry || wRankEntry.currentRank === 0) {
if (!context.nationHasRank) return "";
return format.wrank.noRank({ delta: context.nationHasDelta });
}
const goal = Config.get({ section: "wrank", key: "goal" });
const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta;
return format.wrank.rank(wRankEntry, goal) +
format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder });
}
function formatRow(entry: VoteEntry, context: PollRowContext): string {
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation
? WRank.entry(entry.characterName, entry.characterNation)
: null;
const wrank = formatWRank(wRankEntry, context);
const classStr = entry.characterClass
? (Emoji.class(entry.characterClass) || entry.characterClass)
: "";
const levelStr = entry.characterLevel ? `${entry.characterLevel}` : "";
let row = cfgFormat
.replace("{wrank}", wrank)
.replace("{class}", classStr)
.replace("{level}", levelStr)
.replace("{name}", entry.characterName ?? entry.displayName ?? "")
.replace(/\s+/g, " ")
.trim();
if (nation && entry.userKey) {
const bringer = Bringer.get({ nation });
if (bringer && bringer === entry.characterName) {
row += ` · ${format.bringer(nation)}`;
}
}
if (entry.userKey && context.historyKey) {
if (Leaves.hasLeft({ characterName: entry.characterName!, historyKey: context.historyKey })) {
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName! })}`;
}
}
if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
return row;
}
function buildContext(
entries: VoteEntry[],
nation: Nation,
options?: { showNationEmoji?: boolean; historyKey?: string }
): PollRowContext {
const nationHasRank = entries.some((e) =>
e.characterName && WRank.entry(e.characterName, nation) !== null
);
const nationHasDelta = entries.some((e) => {
const wr = e.characterName ? WRank.entry(e.characterName, nation) : null;
return wr?.previousRank !== undefined;
});
return {
nationHasRank,
nationHasDelta,
showNationEmoji: options?.showNationEmoji ?? false,
historyKey: options?.historyKey,
};
}
// ─── Embed building ───────────────────────────────────────────────────────────
function formatNationField(
nation: Nation,
yesEntries: VoteEntry[],
noVoters: VoteEntry[],
showNoInline: boolean
): string {
const context = buildContext(yesEntries, nation);
const noEntries = showNoInline
? noVoters.filter((e) => e.characterNation === nation)
: [];
const lines = [
...yesEntries.map((e) => formatRow(e, context)),
...noEntries.map((e) => `${formatRow(e, context)}`),
];
return lines.length > 0 ? lines.join("\n") : "—";
}
function formatMessages(
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
): string {
if (allMessages.length === 0) return "";
return allMessages
.map((m) => {
const name = m.entry.characterName ?? m.entry.displayName;
const prefix = m.voteType === "no" ? "✗ " : "✓ ";
const msg = m.entry.publicMessage ? `${m.entry.publicMessage}` : "";
return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
})
.join("\n");
}
function resolveColor(state: PollState): number {
if (state.confirmed === "yes") return 0x57f287;
if (state.confirmed === "no") return 0xed4245;
if (state.locked) return 0x888888;
return 0xe8a317;
}
function resolveTitle(
state: PollState,
yesByNation: Record<Nation, VoteEntry[]>
): string {
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const counts = !state.locked && state.confirmed === null
? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}`
: "";
const statusSuffix =
state.locked ? " 🔒" :
state.confirmed === "yes" ? " ✅" :
state.confirmed === "no" ? " ❌" : "";
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
}
function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
}
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const yesByNation: Record<Nation, VoteEntry[]> = {
[Nation.Capella]: [],
[Nation.Procyon]: [],
};
const noVoters: VoteEntry[] = [];
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
for (const entry of state.yes.values()) {
const nation = entry.characterNation ?? Nation.Capella;
yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" });
}
for (const entry of state.no.values()) {
noVoters.push(entry);
allMessages.push({ entry, voteType: "no" });
}
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const embed = new EmbedBuilder()
.setTitle(resolveTitle(state, yesByNation))
.setColor(resolveColor(state))
.addFields(
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline),
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline),
inline: false,
},
)
.setTimestamp();
const msgSection = formatMessages(allMessages);
if (msgSection) {
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
}
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
return embed;
}
// ─── Layout export ────────────────────────────────────────────────────────────
export const defaultLayout: PollLayout = {
name: "default",
description: "Standard vertical layout with nation-separated fields",
buildEmbed,
formatRow,
buildContext,
};

View file

@ -1,197 +0,0 @@
/**
* Side-by-side poll layout Capella and Procyon displayed as inline fields.
* Nations appear next to each other rather than stacked vertically.
*/
import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation } from "@types";
import { WRankEntry } from "@systems/wrank";
import { Config } from "@systems/config";
import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer";
import { Emoji } from "@systems/emojis";
import { format } from "@format";
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
import { Leaves } from "@systems/leaves";
// ─── Row formatting (same as default) ────────────────────────────────────────
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
if (!wRankEntry || wRankEntry.currentRank === 0) {
if (!context.nationHasRank) return "";
return format.wrank.noRank({ delta: context.nationHasDelta });
}
const goal = Config.get({ section: "wrank", key: "goal" });
const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta;
return format.wrank.rank(wRankEntry, goal) +
format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder });
}
function formatRow(entry: VoteEntry, context: PollRowContext): string {
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation
? WRank.entry(entry.characterName, entry.characterNation)
: null;
const wrank = formatWRank(wRankEntry, context);
const classStr = entry.characterClass ? (Emoji.class(entry.characterClass) || entry.characterClass) : "";
const levelStr = entry.characterLevel ? `${entry.characterLevel}` : "";
let row = cfgFormat
.replace("{wrank}", wrank)
.replace("{class}", classStr)
.replace("{level}", levelStr)
.replace("{name}", entry.characterName ?? entry.displayName ?? "")
.replace(/\s+/g, " ")
.trim();
if (nation && entry.userKey) {
const bringer = Bringer.get({ nation });
if (bringer && bringer === entry.characterName) {
row += ` · ${format.bringer(nation)}`;
}
}
if (entry.userKey && entry.characterName && context.historyKey) {
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
}
}
if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
return row;
}
function buildContext(
entries: VoteEntry[],
nation: Nation,
options?: { showNationEmoji?: boolean; historyKey?: string }
): PollRowContext {
const nationHasRank = entries.some((e) => e.characterName && WRank.entry(e.characterName, nation) !== null);
const nationHasDelta = entries.some((e) => {
const wr = e.characterName ? WRank.entry(e.characterName, nation) : null;
return wr?.previousRank !== undefined;
});
return {
nationHasRank,
nationHasDelta,
showNationEmoji: options?.showNationEmoji ?? false,
historyKey: options?.historyKey,
};
}
// ─── Embed building ───────────────────────────────────────────────────────────
function formatNationField(
nation: Nation,
yesEntries: VoteEntry[],
noVoters: VoteEntry[],
showNoInline: boolean,
historyKey?: string
): string {
const context = buildContext(yesEntries, nation, { historyKey });
const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : [];
const lines = [
...yesEntries.map((e) => formatRow(e, context)),
...noEntries.map((e) => `${formatRow(e, context)}`),
];
return lines.length > 0 ? lines.join("\n") : "—";
}
function formatMessages(allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]): string {
if (allMessages.length === 0) return "";
return allMessages
.map((m) => {
const name = m.entry.characterName ?? m.entry.displayName;
const prefix = m.voteType === "no" ? "✗ " : "✓ ";
const msg = m.entry.publicMessage ? `${m.entry.publicMessage}` : "";
return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
})
.join("\n");
}
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const yesByNation: Record<Nation, VoteEntry[]> = {
[Nation.Capella]: [],
[Nation.Procyon]: [],
};
const noVoters: VoteEntry[] = [];
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
for (const entry of state.yes.values()) {
const nation = entry.characterNation ?? Nation.Capella;
yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" });
}
for (const entry of state.no.values()) {
noVoters.push(entry);
allMessages.push({ entry, voteType: "no" });
}
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
// Title
const counts = !state.locked && state.confirmed === null
? ` ${capellaEmoji} ${yesByNation[Nation.Capella].length} ${procyonEmoji} ${yesByNation[Nation.Procyon].length}`
: "";
const statusSuffix =
state.locked ? " 🔒" :
state.confirmed === "yes" ? " ✅" :
state.confirmed === "no" ? " ❌" : "";
const color =
state.confirmed === "yes" ? 0x57f287 :
state.confirmed === "no" ? 0xed4245 :
state.locked ? 0x888888 :
0xe8a317;
const embed = new EmbedBuilder()
.setTitle(`⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`)
.setColor(color)
.addFields(
// ← inline: true makes them side by side
{
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
inline: true,
},
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: true,
},
)
.setTimestamp();
const msgSection = formatMessages(allMessages);
if (msgSection) {
embed.addFields({ name: "\u200b", value: msgSection, inline: false });
}
// Footer
let footer: string;
if (state.confirmed === "yes") footer = Config.get({ section: "poll", key: "confirmYes" });
else if (state.confirmed === "no") footer = Config.get({ section: "poll", key: "confirmNo" });
else if (state.locked) footer = options?.overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
else footer = `${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
embed.setFooter({ text: footer });
return embed;
}
// ─── Layout export ────────────────────────────────────────────────────────────
export const sideBySideLayout: PollLayout = {
name: "side-by-side",
description: "Nations displayed as inline fields side by side",
buildEmbed,
formatRow,
buildContext,
};

View file

@ -1,53 +1,9 @@
import { EmbedBuilder } from "discord.js";
import { PollState, Nation, VoteEntry } from "@types";
import { Emoji } from "@systems/emojis";
import { PollLayout, PollEmbedOptions } from "@ui/types";
import {
BaseLayout,
buildYesByNation,
formatNationField,
formatMessages,
resolveColor,
resolveTitle,
resolveFooter,
} from "../base-layout";
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
const showNoInline = false; // default layout stacks no-voters
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
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, historyKey),
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
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;
}
import { PollLayout } from "@ui/types";
import { BaseLayout, createBuildEmbed } from "../base-layout";
export const defaultLayout: PollLayout = {
...BaseLayout.methods(),
name: "default",
description: "Standard vertical layout with nation-separated fields",
buildEmbed,
buildEmbed: createBuildEmbed({ inline: false }),
};

View file

@ -1,58 +1,9 @@
import { EmbedBuilder } from "discord.js";
import { PollState, Nation } from "@types";
import { Emoji } from "@systems/emojis";
import { PollLayout, PollEmbedOptions } from "@ui/types";
import {
BaseLayout,
buildYesByNation,
formatNationField,
formatMessages,
resolveColor,
resolveTitle,
resolveFooter,
} from "../base-layout";
function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
const showNoInline = false;
const historyKey = `${new Date().toISOString().slice(0, 10)}-${state.slot}`;
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const maxPlayers = Math.max(
yesByNation[Nation.Capella].length,
yesByNation[Nation.Procyon].length
);
const useInline = maxPlayers <= 5;
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, historyKey),
inline: useInline,
},
{
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
inline: useInline,
},
)
.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;
}
import { PollLayout } from "@ui/types";
import { BaseLayout, createBuildEmbed } from "../base-layout";
export const sideBySideLayout: PollLayout = {
...BaseLayout.methods(),
name: "side-by-side",
description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)",
buildEmbed,
buildEmbed: createBuildEmbed({ inline: true, maxInlinePlayers: 5 }),
};

119
src/ui/result/index.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* 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 { Character, TGScore, TGStats } from "@root/src/types";
import path from "path";
import fs from "fs";
import { WRankWeek } from "@root/src/systems/wrank";
const log = Logger.for("result-ui");
Runtime.phase("restore", () => restoreResultLayout(), { name: "ResultUI.restoreLayout" });
// ─── Types ────────────────────────────────────────────────────────────────────
export interface ResultRow {
character: Character;
score?: TGScore;
position?: { currentRank: number; previousRank?: number };
leavesCount: number;
historyKey: TGKey;
}
export interface ResultLayout {
name: string;
description: string;
buildEmbed(historyKey: TGKey, rows: ResultRow[], week?: WRankWeek|null): EmbedBuilder;
formatRow(row: ResultRow, context: any): string;
}
// ─── Registry ─────────────────────────────────────────────────────────────────
const _layouts = new Map<string, ResultLayout>();
let _active: ResultLayout | null = null;
export function registerResultLayout(layout: ResultLayout): void {
_layouts.set(layout.name, layout);
if (!_active) _active = layout;
}
function isPollLayout(obj: any): obj is ResultLayout {
return obj?.name && obj?.description &&
typeof obj?.buildEmbed === "function" &&
typeof obj?.formatRow === "function";
}
export function discoverResultLayouts(): void {
const dir = path.join(__dirname, "layouts");
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir)
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
.sort();
for (const file of files) {
try {
const mod = require(path.join(dir, file));
for (const exported of Object.values(mod)) {
if (isPollLayout(exported)) {
registerResultLayout(exported as ResultLayout);
log.info(`Registered result layout: ${(exported as ResultLayout).name}`);
}
}
} catch (err: any) {
log.error(`Failed to load result layout ${file}: ${err.message}`);
}
}
}
discoverResultLayouts();
// ─── Restore saved layout ─────────────────────────────────────────────────────
export function restoreResultLayout(): void {
const saved = Config.get({ section: "result", key: "layout" });
if (saved && _layouts.has(saved)) {
_active = _layouts.get(saved)!;
log.info(`Restored result layout: ${saved}`);
}
}
// ─── Helpers ─────────────────────────────────────────────────────
function activeLayout(): ResultLayout {
const layout = _active ?? _layouts.values().next().value;
if (!layout) throw new Error("[ResultUI] No layouts registered");
return layout;
}
// ─── Dispatcher ───────────────────────────────────────────────────────────────
export const ResultUI = {
buildEmbed(historyKey: TGKey, rows: ResultRow[], week?: WRankWeek|null): EmbedBuilder {
return activeLayout().buildEmbed(historyKey, rows, week);
},
formatRow(row: ResultRow, context: any): string {
return activeLayout().formatRow(row, context);
},
setLayout(name: string): boolean {
const layout = _layouts.get(name);
if (!layout) return false;
_active = layout;
return true;
},
layouts(): { name: string; description: string }[] {
return [..._layouts.values()].map((l) => ({ name: l.name, description: l.description }));
},
register: registerResultLayout,
};

View file

@ -0,0 +1,88 @@
/**
* Default result layout stats on new line with prefix (Option B).
*/
import { EmbedBuilder } from "discord.js";
import { Nation, ClassKey } from "@types";
import { TGKey } from "@systems/tg-key";
import { Emoji } from "@systems/emojis";
import { Config } from "@systems/config";
import { format } from "@format";
import { Layout, NationContext } from "@ui/layout";
import { ResultLayout, ResultRow } from "../index";
const TEMPLATE = "{wrank} {class} {level} {name}{indicators} — {score}{kd}";
function formatRow(row: ResultRow, context: NationContext): string {
const char = row.character;
const goal = Config.get({ section: "wrank", key: "goal" });
const wrEntry = Layout.wrankEntry(char as any, row.position, row.score?.pts, 1);
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
const tokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
level: `${char.level}`,
name: char.name,
indicators: Layout.indicators(char as any, { historyKey: row.historyKey }),
score: row.score ? `${Emoji.get("score") || "📊"} ${format.scoreBold(row.score.pts)}` : "—",
kd: row.score ? Layout.kd(row.score.k ?? 0, row.score.d ?? 0) : "",
};
const mainLine = Layout.formatRow(TEMPLATE, tokens);
const statsStr = row.score ? format.stats(row.score.stats) : "";
return statsStr ? `${mainLine}\n┕ ${statsStr}` : mainLine;
}
function buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
const goal = Config.get({ section: "wrank", key: "goal" });
const { date, slot } = TGKey.parse(historyKey);
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
const capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
const proK = procyonRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
const proD = procyonRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const sortByScore = (a: ResultRow, b: ResultRow) =>
(b.score?.pts ?? 0) - (a.score?.pts ?? 0);
const embed = new EmbedBuilder()
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
.setColor(0xe8a317)
.addFields(
{
name: `${capellaEmoji} Capella${(capK || capD) ? `${format.kd(capK, capD)}` : ""}`,
value: [...capellaRows].sort(sortByScore).map((r) => formatRow(r, capContext)).join("\n") || "—",
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon${(proK || proD) ? `${format.kd(proK, proD)}` : ""}`,
value: [...procyonRows].sort(sortByScore).map((r) => formatRow(r, proContext)).join("\n") || "—",
inline: false,
},
)
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
.setTimestamp();
return embed;
}
export const defaultResultLayout: ResultLayout = {
name: "default",
description: "Stats on second line with ┕ prefix",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,85 @@
/**
* 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.stats) : "";
const tokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
level: `${char.level}`,
name: char.name,
indicators: Layout.indicators(char as any, { historyKey: row.historyKey }),
score: row.score ? `${Emoji.get("score") || "📊"} ${format.scoreBold(row.score.pts)}` : "—",
kd: row.score ? Layout.kd(row.score.k ?? 0, row.score.d ?? 0) : "",
stats: statsStr ? `\u2003\u2003${statsStr}` : "", // em spaces for separation
};
return Layout.formatRow(TEMPLATE, tokens);
}
function buildEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
const { date, slot } = TGKey.parse(historyKey);
const capellaRows = rows.filter((r) => r.character.nation === Nation.Capella);
const procyonRows = rows.filter((r) => r.character.nation === Nation.Procyon);
const capContext = Layout.nationContext(capellaRows);
const proContext = Layout.nationContext(procyonRows);
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
const capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
const proK = procyonRows.reduce((s, r) => s + (r.score?.k ?? 0), 0);
const proD = procyonRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon");
const sortByScore = (a: ResultRow, b: ResultRow) =>
(b.score?.pts ?? 0) - (a.score?.pts ?? 0);
const embed = new EmbedBuilder()
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
.setColor(0xe8a317)
.addFields(
{
name: `${capellaEmoji} Capella${(capK || capD) ? `${format.kd(capK, capD)}` : ""}`,
value: [...capellaRows].sort(sortByScore).map((r) => formatRow(r, capContext)).join("\n") || "—",
inline: false,
},
{ name: "\u200b", value: "\u200b", inline: false },
{
name: `${procyonEmoji} Procyon${(proK || proD) ? `${format.kd(proK, proD)}` : ""}`,
value: [...procyonRows].sort(sortByScore).map((r) => formatRow(r, proContext)).join("\n") || "—",
inline: false,
},
)
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
.setTimestamp();
return embed;
}
export const inlineResultLayout: ResultLayout = {
name: "inline",
description: "Stats inline with em space separation",
buildEmbed,
formatRow,
};

View file

@ -0,0 +1,143 @@
/**
* Sequential result layout name, score, and K/D padded via TextAlign,
* computed per-nation. Secondary stats (atk/def/heal) read from the
* nested TGStats shape (row.score.stats), shown on an indented second
* line when present, column-locked under score/kd.
*/
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";
import { WRankWeek } from "@root/src/systems/wrank";
const TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}";
const KD_GAP = 4;
const SCORE_GAP = 4;
const DEF_GAP = 1;
const HEAL_GAP = 4;
function formatRow(
row: ResultRow,
context: NationContext,
allNameBlocks: string[],
allScores: string[],
allKds: string[],
allAtks: string[],
allDefs: string[],
week: WRankWeek | null
): 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) : "—";
const scoreColumn = [...allScores, ...allAtks];
const kdColumn = [...allKds, ...allDefs];
const bringerTag = Layout.bringer(char as any, week ?? undefined);
const nameBlock = bringerTag ? `${char.name}${TextAlign.gap(1)}${bringerTag}` : char.name;
const paddedBlock = TextAlign.padToMax(nameBlock, allNameBlocks);
const cockroach = Layout.cockroach(char as any, row.historyKey);
const tokens: Record<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),
class: Emoji.class(classKey) || classKey || "?",
name: paddedBlock,
indicators: cockroach,
score: `${scoreEmoji} ${TextAlign.padToMax(scoreText, scoreColumn)}`,
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, kdColumn),
};
const mainLine = Layout.formatRow(TEMPLATE, tokens);
const stats = row.score?.stats;
const hasStats = !!stats && !!(stats.atk || stats.def || stats.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", stats!.atk, "⚔️");
const defText = format.statText("anima_def", stats!.def, "🛡️");
const healText = format.statText("circle_massheal_purple", stats!.heal, "💚");
const statsLine = `${prefixGap} ${TextAlign.gap(SCORE_GAP)}${TextAlign.padToMax(atkText, scoreColumn)} ${TextAlign.gap(DEF_GAP)}${TextAlign.padToMax(defText, kdColumn)} ${TextAlign.gap(HEAL_GAP)}${healText}`;
return `${mainLine}\n${statsLine}`;
}
function buildEmbed(historyKey: TGKey, rows: ResultRow[], week: WRankWeek|null): 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 capellaNameBlocks = sortedCapella.map((r) => {
const tag = Layout.bringer(r.character as any, week ?? undefined);
return tag ? `${r.character.name}${TextAlign.gap(1)}${tag}` : r.character.name;
});
const procyonNameBlocks = sortedProcyon.map((r) => {
const tag = Layout.bringer(r.character as any, week ?? undefined);
return tag ? `${r.character.name}${TextAlign.gap(1)}${tag}` : 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("anima_atk") || "⚔️";
const defEmoji = Emoji.get("anima_def") || "🛡️";
const capellaAtks = sortedCapella.map((r) => r.score?.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.stats.atk)}` : "");
const procyonAtks = sortedProcyon.map((r) => r.score?.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.stats.atk)}` : "");
const capellaDefs = sortedCapella.map((r) => r.score?.stats?.def ? `${defEmoji} ${format.number.abbrev(r.score.stats.def)}` : "");
const procyonDefs = sortedProcyon.map((r) => r.score?.stats?.def ? `${defEmoji} ${format.number.abbrev(r.score.stats.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, capellaNameBlocks, capellaScores, capellaKds, capellaAtks, capellaDefs, week));
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNameBlocks, procyonScores, procyonKds, procyonAtks, procyonDefs, week));
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 (nested TGStats) on indented second line",
buildEmbed,
formatRow: formatRow as any,
};

183
src/ui/text-align.ts Normal file
View file

@ -0,0 +1,183 @@
/**
* TextAlign approximate column alignment for Discord embeds using
* invisible filler characters.
*
* Discord embed text is NOT monospace. Character widths below are
* EXACT values extracted directly from Discord's actual font (gg sans
* Regular), via font metrics (advance width / units-per-em), normalized
* to 1.0 = full em. This is real font data, not approximation.
*
* CRITICAL CAVEAT: the invisible filler character used for padding
* (Thin Space U+2009, Hangul Filler U+3164, etc.) does NOT exist in
* gg sans itself confirmed by checking the font's cmap. Discord's
* renderer falls back to some OTHER font (OS/system fallback) to draw
* these glyphs, which we have no programmatic access to measure. This
* means FILLER_WIDTH below MUST remain empirically calibrated via live
* Discord testing it cannot be derived from the gg sans font file.
*
* Usage:
* import { TextAlign } from "@ui/text-align";
*
* const width = TextAlign.estimateWidth("»Flash«");
* const padded = TextAlign.pad("»Flash«", maxWidth);
* const maxWidth = TextAlign.maxWidth(["»Flash«", "XefronYokuda", ...]);
*/
import { Logger } from "@systems/logger";
const log = Logger.for("TextAlign");
const FILLER = "\u2009"; // Thin Space
// ─── Character widths — EXACT, extracted from gg sans Regular.ttf ───────────
// Source: hmtx table advance widths / unitsPerEm (1000). Generated via
// fontTools. Unknown characters fall back to FALLBACK_WIDTH (average of
// lowercase letters, a reasonable default for unmeasured glyphs).
const CHAR_WIDTHS: Record<string, number> = {
"A": 0.644, "B": 0.612, "C": 0.636, "D": 0.658, "E": 0.56,
"F": 0.545, "G": 0.67, "H": 0.67, "I": 0.242, "J": 0.513,
"K": 0.605, "L": 0.556, "M": 0.838, "N": 0.681, "O": 0.684,
"P": 0.607, "Q": 0.684, "R": 0.623, "S": 0.556, "T": 0.532,
"U": 0.668, "V": 0.602, "W": 0.816, "X": 0.588, "Y": 0.59,
"Z": 0.551,
"a": 0.509, "b": 0.553, "c": 0.504, "d": 0.553, "e": 0.507,
"f": 0.337, "g": 0.503, "h": 0.538, "i": 0.228, "j": 0.228,
"k": 0.488, "l": 0.253, "m": 0.812, "n": 0.528, "o": 0.53,
"p": 0.553, "q": 0.553, "r": 0.377, "s": 0.452, "t": 0.383,
"u": 0.528, "v": 0.472, "w": 0.702, "x": 0.458, "y": 0.47,
"z": 0.438,
"0": 0.584, "1": 0.356, "2": 0.519, "3": 0.546, "4": 0.556,
"5": 0.536, "6": 0.535, "7": 0.458, "8": 0.528, "9": 0.535,
" ": 0.22, ".": 0.242, ",": 0.242, "'": 0.178, "!": 0.234,
"»": 0.462, "«": 0.462, "-": 0.408, "_": 0.508, "/": 0.438,
"[": 0.31, "]": 0.31, ":": 0.242,
};
const FALLBACK_WIDTH = 0.5; // reasonable default for unmeasured characters
// Estimated width of the invisible filler character itself, relative to
// one standard (uppercase) letter, SPECIFICALLY INSIDE EMBEDS using Thin
// Space (U+2009). CALIBRATED via live embed testing — see file header.
const FILLER_WIDTH = 0.203;
// Discord custom emoji tags <:name:id> or <a:name:id> (animated) render as
// ONE fixed-width visual glyph regardless of how long the internal name/ID
// text happens to be. Without this, estimateWidth would wrongly measure
// the RAW TAG TEXT character-by-character, making emoji with longer
// internal names appear "wider" than equally-sized emoji with shorter
// names — a category error, since emoji width has nothing to do with
// their Discord ID string length.
const EMOJI_TAG_REGEX = /<a?:\w+:\d+>/g;
// Estimated visual width of a single custom emoji, roughly equivalent to
// one full em (similar to a wide character). Approximate — emoji can vary
// slightly by their actual artwork, but this is consistent enough for
// alignment purposes.
const EMOJI_WIDTH = 1.0;
function charWidth(ch: string): number {
if (ch === FILLER) return FILLER_WIDTH;
return CHAR_WIDTHS[ch] ?? FALLBACK_WIDTH;
}
// ─── Namespace ────────────────────────────────────────────────────────────────
export const TextAlign = {
/**
* Estimate the visual width of a string in "em units" (1.0 = one full em).
* Uses exact gg sans font metrics for text. Discord custom emoji tags
* (<:name:id>) are detected and treated as ONE fixed-width unit each,
* NOT measured character-by-character (their internal ID text has no
* bearing on the emoji's actual rendered width).
*/
estimateWidth(text: string): number {
let total = 0;
let lastIndex = 0;
for (const match of text.matchAll(EMOJI_TAG_REGEX)) {
const matchStart = match.index!;
const plainText = text.slice(lastIndex, matchStart);
total += plainText.split("").reduce((sum, ch) => sum + charWidth(ch), 0);
total += EMOJI_WIDTH;
lastIndex = matchStart + match[0].length;
}
const remaining = text.slice(lastIndex);
total += remaining.split("").reduce((sum, ch) => sum + charWidth(ch), 0);
return total;
},
/**
* Get the max estimated width across a list of strings.
*/
maxWidth(texts: string[]): number {
return Math.max(...texts.map((t) => TextAlign.estimateWidth(t)), 0);
},
/**
* Pad a string with invisible filler characters to reach a target width.
*/
pad(text: string, targetWidth: number): string {
const current = TextAlign.estimateWidth(text);
const diff = targetWidth - current;
const fillerCount = diff > 0 ? Math.round(diff / FILLER_WIDTH) : 0;
log.debug(`"${text}": estimatedWidth=${current.toFixed(3)} target=${targetWidth.toFixed(3)} diff=${diff.toFixed(3)} fillerCount=${fillerCount}`);
if (fillerCount <= 0) return text;
return text + FILLER.repeat(fillerCount);
},
/**
* Pad a string with invisible filler characters BEFORE it (prefix),
* so a fixed-width value (e.g. before a separator) ends up
* right-aligned relative to the target width.
*/
padLeft(text: string, targetWidth: number): string {
const current = TextAlign.estimateWidth(text);
const diff = targetWidth - current;
const fillerCount = diff > 0 ? Math.round(diff / FILLER_WIDTH) : 0;
if (fillerCount <= 0) return text;
return FILLER.repeat(fillerCount) + text;
},
/**
* Pad a string to match the widest string in a list (convenience).
*/
padToMax(text: string, allTexts: string[]): string {
return TextAlign.pad(text, TextAlign.maxWidth(allTexts));
},
/**
* Pad-left a string to match the widest string in a list (convenience).
*/
padLeftToMax(text: string, allTexts: string[]): string {
return TextAlign.padLeft(text, TextAlign.maxWidth(allTexts));
},
/**
* Pad a string to match the widest string in a list, with a small
* width offset applied to the target for fine-tuning a specific
* value's alignment relative to a shared column without affecting
* the column's target width for other values.
* offset > 0 pads MORE (pushes right), offset < 0 pads LESS (pulls left).
*/
padToMaxOffset(text: string, allTexts: string[], offset: number): string {
return TextAlign.pad(text, TextAlign.maxWidth(allTexts) + offset);
},
/**
* Pad-left variant of padToMaxOffset.
*/
padLeftToMaxOffset(text: string, allTexts: string[], offset: number): string {
return TextAlign.padLeft(text, TextAlign.maxWidth(allTexts) + offset);
},
/**
* Get N filler characters as a standalone spacing buffer, for adding
* extra breathing room between columns beyond what alignment requires.
*/
gap(n: number = 1): string {
return FILLER.repeat(Math.max(0, n));
},
};

View file

@ -4,6 +4,7 @@
import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation } from "@types";
import { TGKey } from "@systems/tg-key";
// ─── Poll ─────────────────────────────────────────────────────────────────────
@ -11,12 +12,13 @@
nationHasRank: boolean;
nationHasDelta: boolean;
showNationEmoji?: boolean;
historyKey?: string
historyKey?: TGKey;
}
export interface PollEmbedOptions {
overrideLockMsg?: string;
showScoreButton?: boolean;
historyKey?: TGKey;
}
export interface PollLayout {

View file

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