Compare commits

...
Sign in to create a new pull request.

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/leaves.json
data/sessionPreferences.json data/sessionPreferences.json
data/tg-history/ data/tg-history/
data/updates/.message-ids.json
data/.message-ids/
data/snapshots/
# Emoji data # Emoji data
emoji-uploads/ 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_down": "<:wrank_down:1511906547104616643>",
"wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>", "wrank_neutral_0": "<:wrank_neutral_0:1511950717290545354>",
"wrank_up": "<:wrank_up:1512114414474756132>", "wrank_up": "<:wrank_up:1512114414474756132>",
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>" "wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>",
"slash": "<:slash:1516648012422844416>",
"tg_flag": "<:tg_flag:1516946073392910336>"
} }

View file

@ -98,5 +98,6 @@
"wrank_down_96": "<:wrank_down_96:1513360449276477509>", "wrank_down_96": "<:wrank_down_96:1513360449276477509>",
"wrank_down_97": "<:wrank_down_97:1513360453152280709>", "wrank_down_97": "<:wrank_down_97:1513360453152280709>",
"wrank_down_98": "<:wrank_down_98:1513360459758174328>", "wrank_down_98": "<:wrank_down_98:1513360459758174328>",
"wrank_down_99": "<:wrank_down_99:1513360463558082590>" "wrank_down_99": "<:wrank_down_99:1513360463558082590>",
"wrank_down_0": "<:wrank_down_0:1516648019297046709>"
} }

View file

@ -98,5 +98,6 @@
"wrank_up_96": "<:wrank_up_96:1513360762666746008>", "wrank_up_96": "<:wrank_up_96:1513360762666746008>",
"wrank_up_97": "<:wrank_up_97:1513360766588424192>", "wrank_up_97": "<:wrank_up_97:1513360766588424192>",
"wrank_up_98": "<:wrank_up_98:1513360770895708303>", "wrank_up_98": "<:wrank_up_98:1513360770895708303>",
"wrank_up_99": "<:wrank_up_99:1513360776071745576>" "wrank_up_99": "<:wrank_up_99:1513360776071745576>",
"wrank_up_0": "<:wrank_up_0:1516648023084503113>"
} }

View file

@ -18,5 +18,6 @@
"wrank_6": "<:wrank_6:1512124952738795581>", "wrank_6": "<:wrank_6:1512124952738795581>",
"wrank_7": "<:wrank_7:1512124956622979143>", "wrank_7": "<:wrank_7:1512124956622979143>",
"wrank_8": "<:wrank_8:1512124961450496020>", "wrank_8": "<:wrank_8:1512124961450496020>",
"wrank_9": "<:wrank_9:1512124965363650631>" "wrank_9": "<:wrank_9:1512124965363650631>",
"wrank_0": "<:wrank_0:1516648016008712243>"
} }

View file

@ -27,7 +27,7 @@
"userKey": "ayana", "userKey": "ayana",
"displayName": "Ayana", "displayName": "Ayana",
"characterName": "«MonkeyHunter»", "characterName": "«MonkeyHunter»",
"characterClass": "GL", "characterClass": "DM",
"characterLevel": 79, "characterLevel": 79,
"characterNation": "Procyon", "characterNation": "Procyon",
"votedAt": "19:46" "votedAt": "19:46"
@ -36,7 +36,7 @@
"no": [], "no": [],
"leaves": [ "leaves": [
{ {
"characterName": "»Flash«", "characterName": " «Deystroyer»",
"historyKey": "2026-06-11-20" "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", "latest": "v0.9.1",
"versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.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_96": "<:wrank_up_96:1513360762666746008>",
"wrank_up_97": "<:wrank_up_97:1513360766588424192>", "wrank_up_97": "<:wrank_up_97:1513360766588424192>",
"wrank_up_98": "<:wrank_up_98:1513360770895708303>", "wrank_up_98": "<:wrank_up_98:1513360770895708303>",
"wrank_up_99": "<:wrank_up_99:1513360776071745576>" "wrank_up_99": "<:wrank_up_99:1513360776071745576>",
"slash": "<:slash:1516648012422844416>",
"wrank_0": "<:wrank_0:1516648016008712243>",
"wrank_down_0": "<:wrank_down_0:1516648019297046709>",
"wrank_up_0": "<:wrank_up_0:1516648023084503113>",
"tg_flag": "<:tg_flag:1516946073392910336>",
"anima_anima_atk": "<:anima_anima_atk:1517697805181784126>",
"anima_anima_def": "<:anima_anima_def:1517700444116484246>",
"anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>",
"anima_atk": "<:anima_atk:1517702182710018179>",
"anima_def": "<:anima_def:1517700561468657785>",
"anima_massheal": "<:anima_massheal:1517702186434433146>",
"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", "start": "ts-node -r tsconfig-paths/register src/index.ts",
"dev": "nodemon", "dev": "nodemon",
"register": "ts-node -r tsconfig-paths/register src/index.ts --register", "register": "ts-node -r tsconfig-paths/register src/index.ts --register",
"aliases": "ts-node scripts/generate-aliases.ts" "aliases": "ts-node scripts/generate-aliases.ts",
"upload-emojis": "ts-node -r tsconfig-paths/register scripts/upload-emojis.ts",
"split-emojis": "ts-node -r tsconfig-paths/register scripts/split-emojis.ts"
}, },
"dependencies": { "dependencies": {
"discord.js": "^14.15.3", "discord.js": "^14.15.3",

View file

@ -31,11 +31,13 @@
} }
} }
const TOKEN = process.env.DISCORD_TOKEN!; Config.load();
const DONOR_GUILD_IDS: string[] = Config.get("emojiDonorGuilds");
const TOKEN = process.env.DISCORD_TOKEN!;
const DONOR_GUILD_IDS = Config.get({ section: "emoji", key: "donorGuilds" });
if (!TOKEN || DONOR_GUILD_IDS.length === 0) { if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env"); console.error("❌ DISCORD_TOKEN must be set in .env and emoji.donorGuilds must be configured in config.json");
process.exit(1); process.exit(1);
} }
@ -50,11 +52,13 @@
// Custom naming functions per dir — (filename without ext) → emoji name // Custom naming functions per dir — (filename without ext) → emoji name
const DIR_NAME_MAP: Record<string, (filename: string) => string> = { const DIR_NAME_MAP: Record<string, (filename: string) => string> = {
"wrank": (f) => `wrank_${f}`, "wrank": (f) => `wrank_${f}`,
"wrank_gold": (f) => `wrank_${f}_gold`, "wrank_gold": (f) => `wrank_${f}_gold`,
"wrank_up": (f) => `wrank_up_${f}`, "wrank_up": (f) => `wrank_up_${f}`,
"wrank_down": (f) => `wrank_down_${f}`, "wrank_down": (f) => `wrank_down_${f}`,
"wrank_x": (f) => `wrank_x_${f}`, "wrank_x": (f) => `wrank_x_${f}`,
"anima-mastery_stats": (f) => `anima_${f}`,
"circle": (f) => `circle_${f}`,
}; };
function resolveEmojiName(dirName: string, filename: string): string { function resolveEmojiName(dirName: string, filename: string): string {

View file

@ -57,11 +57,27 @@ import { handleCharActive } from "@subcommands/char/active";
import { Nation } from "@types"; import { Nation } from "@types";
import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left"; import { handleMarkLeft, handleUnmarkLeft } from "@subcommands/poll/mark-left";
import { CallCommands } from "@subcommands/poll/call";
import { ConfirmNoCommands } from "@subcommands/poll/confirm-no";
export function buildTgCommand(): SlashCommandBuilder { export function buildTgCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
.setName("tg") .setName("tg")
.setDescription("TG planning and tracking"); .setDescription("TG planning and tracking");
// ── root ─────────────────────────────────────────────────────────────
cmd.addSubcommand((s) => s
.setName("call")
.setDescription("Call TG early (ended before 35 min)")
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true).setAutocomplete(true))
)
cmd.addSubcommand((s) => s
.setName("confirm-no")
.setDescription("Confirm TG is cancelled")
.addIntegerOption((o) => o.setName("slot").setDescription("TG slot hour").setRequired(true).setAutocomplete(true))
)
// ── poll group ───────────────────────────────────────────────────────────── // ── poll group ─────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
.setName("poll") .setName("poll")
@ -327,6 +343,7 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
if (sub === "seed") return handleSeed(interaction); if (sub === "seed") return handleSeed(interaction);
if (sub === "mark-left") return handleMarkLeft(interaction); if (sub === "mark-left") return handleMarkLeft(interaction);
if (sub === "unmark-left") return handleUnmarkLeft(interaction); if (sub === "unmark-left") return handleUnmarkLeft(interaction);
if (sub === "confirm-no") return ConfirmNoCommands.confirmNo(interaction);
} }
if (group === "score") { if (group === "score") {
if (sub === "set") return handleScoreSet(interaction); if (sub === "set") return handleScoreSet(interaction);
@ -361,4 +378,6 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
if (!group && sub === "switch") return handleSwitch(interaction); if (!group && sub === "switch") return handleSwitch(interaction);
if (!group && sub === "history") return handleHistory(interaction); if (!group && sub === "history") return handleHistory(interaction);
if (!group && sub === "impersonate") return handleImpersonate(interaction); if (!group && sub === "impersonate") return handleImpersonate(interaction);
if (!group && sub === "call") return CallCommands.call(interaction);
} }

View file

@ -5,7 +5,11 @@ import {
handleAdminPollShowEntry, handleAdminPollShowEntry,
} from "@subcommands/admin/userMap"; } from "@subcommands/admin/userMap";
import { UpdatesCommands } from "@subcommands/admin/updates"; import { UpdatesCommands } from "@subcommands/admin/updates";
import { ScoreInjectCommands } from "@subcommands/admin/score-inject";
import { ResultCommands } from "@subcommands/admin/result-post";
import { TestAlignCommands } from "@subcommands/admin/test-align";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
export function buildTgAdminCommand(): SlashCommandBuilder { export function buildTgAdminCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
@ -71,39 +75,123 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
); );
cmd.addSubcommandGroup((g) => g cmd.addSubcommandGroup((g) => g
.setName("updates") .setName("updates")
.setDescription("Manage update posts") .setDescription("Manage update posts")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post or edit an update embed")
.addStringOption((o) => o
.setName("version")
.setDescription("Version to post (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("preview")
.setDescription("Preview an update embed (ephemeral)")
.addStringOption((o) => o
.setName("version")
.setDescription("Version to preview (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("list")
.setDescription("List all versions and their post status")
)
)
// ── result group ─────────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("result")
.setDescription("Manage TG results")
.addSubcommand((s) => s
.setName("post")
.setDescription("Post or edit a TG result")
.addStringOption((o) => o.setName("history_key").setDescription("TG to post").setRequired(true).setAutocomplete(true))
)
)
// ── leaderboard group ─────────────────────────────────────────────────────────────────
cmd.addSubcommandGroup((g) => g
.setName("leaderboard")
.setDescription("Manage leaderboard")
.addSubcommand((s) => s .addSubcommand((s) => s
.setName("post") .setName("post")
.setDescription("Post or edit an update embed") .setDescription("Post or edit the leaderboard")
.addStringOption((o) => o .addStringOption((o) => o.setName("week_key").setDescription("Week e.g. 2026-W24 (defaults current)").setAutocomplete(true))
.setName("version")
.setDescription("Version to post (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
) )
.addSubcommand((s) => s .addSubcommand((s) => s
.setName("preview") .setName("post-highlights")
.setDescription("Preview an update embed (ephemeral)") .setDescription("Post or edit the leaderboard highlights")
.addStringOption((o) => o .addStringOption((o) => o.setName("week_key").setDescription("Week e.g. 2026-W24 (defaults current)").setAutocomplete(true))
.setName("version")
.setDescription("Version to preview (defaults to latest)")
.setRequired(false)
.setAutocomplete(true)
)
)
.addSubcommand((s) => s
.setName("list")
.setDescription("List all versions and their post status")
) )
) )
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; return cmd;
} }
export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleTgAdminCommand(interaction: ChatInputCommandInteraction): Promise<void> {
const group = interaction.options.getSubcommandGroup(true); const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
if (group === "user") { if (group === "user") {
@ -122,4 +210,15 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
if (sub === "preview") return UpdatesCommands.preview(interaction); if (sub === "preview") return UpdatesCommands.preview(interaction);
if (sub === "list") return UpdatesCommands.list(interaction); if (sub === "list") return UpdatesCommands.list(interaction);
} }
if (group === null && sub === "score-inject") return ScoreInjectCommands.inject(interaction);
if (group === "result" && sub === "post") return ResultCommands.post(interaction);
if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction);
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction);
if (group === "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 { replyAndDelete } from "../utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { handleSetLayout } from "@subcommands/tg-config/set-layout"; import { handleSetLayout } from "@subcommands/tg-config/set-layout";
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
import { SetLayoutCommands } from "@subcommands/tg-config/set-layout";
const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = { const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = {
officerRoles: "officer", officerRoles: "officer",
@ -123,6 +126,24 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
)) ))
); );
// In poll group or as separate groups:
cmd.addSubcommand((s) => s
.setName("set-result-layout")
.setDescription("Change the TG result display layout")
.addStringOption((o) => o
.setName("layout").setDescription("Layout name")
.setRequired(true).setAutocomplete(true)
)
);
cmd.addSubcommand((s) => s
.setName("set-leaderboard-layout")
.setDescription("Change the leaderboard display layout")
.addStringOption((o) => o
.setName("layout").setDescription("Layout name")
.setRequired(true).setAutocomplete(true)
)
);
return cmd; return cmd;
} }
@ -137,7 +158,7 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }
const group = options.getSubcommandGroup(true); const group = options.getSubcommandGroup();
const sub = options.getSubcommand(); const sub = options.getSubcommand();
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
@ -242,4 +263,8 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
if (group === "poll") { if (group === "poll") {
if (sub === "set-layout") return handleSetLayout(interaction); if (sub === "set-layout") return handleSetLayout(interaction);
} }
if (group === null && sub === "set-result-layout") return SetResultLayoutCommands.handle(interaction);
if (group === null && sub === "set-leaderboard-layout") return SetLeaderboardLayoutCommands.handle(interaction);
if (group === null && sub === "set-layout") return SetLayoutCommands.handle(interaction);
} }

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

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

View file

@ -9,17 +9,20 @@
* Discord.Channel.fetch({ client, id }) * Discord.Channel.fetch({ client, id })
*/ */
export { Interaction } from "./interaction"; export { Interaction } from "./interaction";
export { Guild } from "./guild"; export { Guild } from "./guild";
export { Channel } from "./channel"; export { Channel } from "./channel";
// Top-level namespace for convenience // Top-level namespace for convenience
import { Interaction } from "./interaction"; import { Interaction } from "./interaction";
import { Guild } from "./guild"; import { Guild } from "./guild";
import { Channel } from "./channel"; import { Channel } from "./channel";
// ───────────────────────────────────────────────────────────────────────────────
export const Discord = {
Interaction,
Guild,
Channel,
};
export const Discord = {
Interaction,
Guild,
Channel,
};

View file

@ -10,7 +10,11 @@ import { Nation } from "@types";
import { NATION_UNICODE } from "@systems/nations"; import { NATION_UNICODE } from "@systems/nations";
import { autocompleteLayout } from "@subcommands/tg-config/set-layout"; import { autocompleteLayout } from "@subcommands/tg-config/set-layout";
import { UpdatesCommands } from "@subcommands/admin/updates"; import { UpdatesCommands } from "@subcommands/admin/updates";
import { ResultCommands } from "@subcommands/admin/result-post";
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
import fs from "fs"; import fs from "fs";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
// ─── Usermap cache ──────────────────────────────────────────────────────────── // ─── Usermap cache ────────────────────────────────────────────────────────────
let _usermapCache: Record<string, any> | null = null; let _usermapCache: Record<string, any> | null = null;
@ -42,8 +46,11 @@ async function autocompleteCharNames(
.filter((c) => !nation || c.nation === nation) .filter((c) => !nation || c.nation === nation)
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
.map((c) => { .map((c) => {
const nationEmoji = c.nation ? (NATION_UNICODE[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(), value: c.name }; return {
name: `${classKey} ${c.level} ${c.name} [${c.nation}]`.trim(),
value: c.name
};
}) })
.slice(0, 25); .slice(0, 25);
return interaction.respond(results); return interaction.respond(results);
@ -53,18 +60,18 @@ async function autocompleteCharNames(
// Own chars // Own chars
const ownChars = getCharacters(user.userKey).map((c) => { 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 { return {
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), name: `${classKey} ${c.level} ${c.name} [${c.nation}]`.trim(),
value: c.name, value: c.name,
}; };
}); });
// Shared chars // Shared chars
const sharedChars = CharacterRegistry.sharedWith(user.userKey).map(({ char }) => { 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 { return {
name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(), name: `${classKey} ${char.level} ${char.name} [${char.nation}] 🔗`.trim(),
value: char.name, value: char.name,
}; };
}); });
@ -129,8 +136,17 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
if (optionName === "layout") return await autocompleteLayout(interaction);
if (optionName === "version") return UpdatesCommands.autocomplete(interaction); if (optionName === "version") return UpdatesCommands.autocomplete(interaction);
if (optionName === "history_key") return await ResultCommands.autocompleteHistory(interaction);
if (optionName === "week_key") return await ResultCommands.autocompleteWeekKey(interaction);
if (optionName === "layout") {
if (sub === "set-result-layout") return await SetResultLayoutCommands.autocomplete(interaction);
if (sub === "set-leaderboard-layout") return await SetLeaderboardLayoutCommands.autocomplete(interaction);
return await autocompleteLayout(interaction); // poll default
}
if (optionName === "id") return await AnnouncementCommands.autocomplete(interaction);
await interaction.respond([]); await interaction.respond([]);
} catch (err) { } catch (err) {

View file

@ -8,7 +8,11 @@ import { TGSlot } from "@src/types";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { buildTgAdminCommand } from "@commands/tgAdmin"; import { buildTgAdminCommand } from "@commands/tgAdmin";
import { Scheduler } from "@systems/scheduler"; import { Scheduler } from "@systems/scheduler";
import { Runtime } from "@systems/runtime"; import { Runtime, RuntimeEvents } from "@systems/runtime";
import { Leaderboard } from "@systems/leaderboard";
import { Result } from "@systems/result";
import { Attendance } from "@systems/attendance";
import { DiscordClient } from "@src/discord/client";
const TOKEN = process.env.DISCORD_TOKEN!; const TOKEN = process.env.DISCORD_TOKEN!;
const CLIENT_ID = process.env.CLIENT_ID!; const CLIENT_ID = process.env.CLIENT_ID!;
@ -73,6 +77,20 @@ client.once("clientReady", async () => {
await Runtime.start(); await Runtime.start();
DiscordClient.set(client);
// Register event handlers
RuntimeEvents.on("scoreSubmitted", async ({ historyKey }) => {
await Leaderboard.update();
if (Attendance.allSubmitted(historyKey)) {
RuntimeEvents.emit("allScoresSubmitted", { historyKey });
}
});
RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
await Result.post({ historyKey });
});
const restored = persist.load(); const restored = persist.load();
if (restored) { if (restored) {
for (const [slot, state] of restored) polls.set(slot, state); for (const [slot, state] of restored) polls.set(slot, state);

View file

@ -0,0 +1,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 { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { Config } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; 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 { replyAndDelete } from "../../utils";
import { Emoji } from "@systems/emojis";
export async function handleCharActive(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharActive(interaction: ChatInputCommandInteraction): Promise<void> {
const nameArg = interaction.options.getString("name"); const nameArg = interaction.options.getString("name");
@ -22,8 +20,8 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction)
const { char, borrowedFrom } = getEffectiveCharacter(targetKey); const { char, borrowedFrom } = getEffectiveCharacter(targetKey);
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`); if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`);
const { getClassEmoji } = require("../../systems/emojis"); const classKey = typeof char.class === "object" ? char.class?.key : char.class;
const classEmoji = getClassEmoji(char.class) || char.class; const classEmoji = Emoji.class(classKey) || classKey;
const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
return void replyAndDelete(interaction, `${classEmoji} ${char.level} ${char.name}${borrowed}`); 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 { polls, updatePollMessage } from "@systems/poll";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { TGKey } from "@systems/tg-key";
function getCurrentHistoryKey(slot: number): string {
const date = new Date().toISOString().slice(0, 10);
return `${date}-${slot}`;
}
export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleMarkLeft(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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]; const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true);
const historyKey = getCurrentHistoryKey(slot); const historyKey = TGKey.current({ slot });
Leaves.mark({ Leaves.mark({
characterName: char.name, characterName: char.name,
@ -59,7 +55,7 @@ export async function handleUnmarkLeft(interaction: ChatInputCommandInteraction)
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll.", true); 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 }); Leaves.unmark({ characterName: char.name, historyKey });
const channel = interaction.channel as TextChannel; const channel = interaction.channel as TextChannel;

View file

@ -50,13 +50,13 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
? `\n*(played by ${(score as any).playedBy})*` ? `\n*(played by ${(score as any).playedBy})*`
: ""; : "";
const lines = [ const lines = [
`**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`, `**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`,
`${scoreEmoji} **${score.pts}** pts`, `${scoreEmoji} **${score.pts}** pts`,
score.atk !== undefined ? `ATK: ${score.atk}` : null, score.stats?.atk !== undefined ? `ATK: ${score.stats.atk}` : null,
score.def !== undefined ? `DEF: ${score.def}` : null, score.stats?.def !== undefined ? `DEF: ${score.stats.def}` : null,
score.heal !== undefined ? `HEAL: ${score.heal}` : null, score.stats?.heal !== undefined ? `HEAL: ${score.stats.heal}` : null,
`*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`, `*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`,
].filter(Boolean).join("\n"); ].filter(Boolean).join("\n");
return void replyAndDelete(interaction, lines, true); return void replyAndDelete(interaction, lines, true);

View file

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

View file

@ -2,7 +2,10 @@ import { Config } from "@systems/config";
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { format } from "@format"; 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 ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -50,6 +53,8 @@ export namespace score {
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20; 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({ await submitScore({
userKey: borrowedFrom ?? userKey, userKey: borrowedFrom ?? userKey,
playedBy: borrowedFrom ? userKey : undefined, playedBy: borrowedFrom ? userKey : undefined,
@ -66,9 +71,8 @@ export namespace score {
submittedByOfficer, submittedByOfficer,
}); });
const scoreEmoji = getEmoji("score") || "📊"; const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = getEmoji("kd") || "⚔️"; const kdEmoji = Emoji.get("kd") || "⚔️";
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : ""; const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
const statsNote = [ const statsNote = [
atk !== undefined ? `ATK: ${atk}` : null, atk !== undefined ? `ATK: ${atk}` : null,
@ -77,10 +81,22 @@ export namespace score {
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
const charDisplay = format.char(char); const charDisplay = format.char(char);
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
const line = format.scoreSubmitLine({
slot,
char,
pts,
k,
d,
atk,
def,
heal,
});
return { return {
ok: true, ok: true,
message: `${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, message: `${line}${borrowNote}`,
// message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
}; };
} }
} }

View file

@ -1,47 +1,38 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { PollUI } from "@ui/poll"; import { PollUI } from "@ui/poll";
import { replyAndDelete } from "@utils"; import { Discord } from "@discord";
import { hasOfficerRole } from "@systems/users"; import { hasOfficerRole } from "@systems/users";
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
// discord.js types CommandInteractionOptionResolver with Omit<> which hides
// methods like getString/getInteger at the type level despite them existing at runtime
// Needs to be cast as any, since Discord.js has issues with the type
const options = interaction.options as any;
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ Only officers can change the poll layout.", true); await Discord.Interaction.reply(interaction, { content: "❌ Officer only.", ephemeral: true });
return;
} }
const name = options.getString("layout", true); const opts = Discord.Interaction.options(interaction);
const name = opts.string({ key: "layout", required: true })!;
if (!PollUI.setLayout(name)) { if (!PollUI.setLayout(name)) {
const available = PollUI.layouts() const available = PollUI.layouts().map((l) => `\`${l.name}\`${l.description}`).join("\n");
.map((l) => `\`${l.name}\`${l.description}`) await Discord.Interaction.reply(interaction, { content: `❌ Layout \`${name}\` not found.\n${available}`, ephemeral: true });
.join("\n"); return;
return void replyAndDelete(interaction,
`❌ Layout \`${name}\` not found. Available layouts:\n${available}`, true
);
} }
Config.set({ section: "poll", key: "layout", value: name }); Config.set({ section: "poll", key: "layout", value: name });
await Discord.Interaction.reply(interaction, { content: `✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, ephemeral: true });
return void replyAndDelete(interaction,
`✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true
);
} }
export async function autocompleteLayout(interaction: any): Promise<void> { export async function autocompleteLayout(interaction: any): Promise<void> {
// discord.js types CommandInteractionOptionResolver with Omit<> which hides const focused = interaction.options.getFocused().toLowerCase();
// methods like getString/getInteger at the type level despite them existing at runtime
// Needs to be cast as any, since Discord.js has issues with the type
const options = interaction.options as any;
const focused = options.getFocused().toLowerCase();
const choices = PollUI.layouts() const choices = PollUI.layouts()
.filter((l) => l.name.includes(focused)) .filter((l) => l.name.includes(focused))
.map((l) => ({ name: `${l.name}${l.description}`, value: l.name })); .map((l) => ({ name: `${l.name}${l.description}`, value: l.name }));
await interaction.respond(choices); await interaction.respond(choices);
} }
export const SetLayoutCommands = {
handle: handleSetLayout,
autocomplete: autocompleteLayout,
};

View file

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

View file

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

View file

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

View file

@ -9,16 +9,19 @@ Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 }
// ─── Section interfaces (internal) ─────────────────────────────────────────── // ─── Section interfaces (internal) ───────────────────────────────────────────
interface ChannelConfig { interface ChannelConfig {
poll: string; poll: string;
results: string; results: string;
score: string; score: string;
updates: string; updates: string;
leaderboard: string;
announcements: string;
} }
interface RoleConfig { interface RoleConfig {
officer: string[]; officer: string[];
config: string[]; config: string[];
tag: string[]; tag: string[];
callGame: string[];
} }
interface PollConfig { interface PollConfig {
@ -38,9 +41,20 @@ interface PollConfig {
autoVoteOnConflict: boolean; autoVoteOnConflict: boolean;
reclaimNotifyBorrower: boolean; reclaimNotifyBorrower: boolean;
conflictReclaimBehavior: string; conflictReclaimBehavior: string;
calledGameImageUrl?: string;
cancelledImageUrl?: string;
calledMessage?: string;
slots: TGSlot[]; slots: TGSlot[];
} }
interface ResultConfig {
layout: string;
}
interface LeaderboardConfig {
layout: string;
}
interface WRankConfig { interface WRankConfig {
goal: number; goal: number;
postOnReset: boolean; postOnReset: boolean;
@ -81,16 +95,18 @@ interface TGConfig {
// ─── Section map ────────────────────────────────────────────────────────────── // ─── Section map ──────────────────────────────────────────────────────────────
export interface SectionMap { export interface SectionMap {
channels: ChannelConfig; channels: ChannelConfig;
roles: RoleConfig; roles: RoleConfig;
poll: PollConfig; poll: PollConfig;
wrank: WRankConfig; result: ResultConfig;
bringer: BringerConfig; leaderboard: LeaderboardConfig;
impersonate:ImpersonateConfig; wrank: WRankConfig;
emoji: EmojiConfig; bringer: BringerConfig;
nation: NationConfig; impersonate: ImpersonateConfig;
borrow: BorrowConfig; emoji: EmojiConfig;
tg: TGConfig; nation: NationConfig;
borrow: BorrowConfig;
tg: TGConfig;
} }
export type ConfigSection = keyof SectionMap; export type ConfigSection = keyof SectionMap;
@ -103,12 +119,15 @@ function getDefaults(): SectionMap {
poll: "", poll: "",
results: "", results: "",
score: "", score: "",
updates: "" updates: "",
leaderboard: "",
announcements: ""
}, },
roles: { roles: {
officer: ["Ice King"], officer: ["Ice King"],
config: ["Ice King"], config: ["Ice King"],
tag: ["Ice King", "Ice", "Rebellion"], tag: ["Ice King", "Ice", "Rebellion"],
callGame: ["Ice King"],
}, },
poll: { poll: {
layout: "default", layout: "default",
@ -129,6 +148,12 @@ function getDefaults(): SectionMap {
conflictReclaimBehavior: "revert", conflictReclaimBehavior: "revert",
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
}, },
result: {
layout: "default"
},
leaderboard: {
layout: "default"
},
wrank: { wrank: {
goal: 7, goal: 7,
postOnReset: false, postOnReset: false,

View file

@ -1,6 +1,7 @@
import { Character, CharacterClass, Nation } from "@src/types"; import { Character, CharacterClass, ClassKey, Nation, TGStats } from "@src/types";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { WRankEntry } from "@systems/wrank"; import { WRankEntry } from "@systems/wrank";
import { LeaderboardEntry } from "./leaderboard";
// ─── Individual formatters ──────────────────────────────────────────────────── // ─── Individual formatters ────────────────────────────────────────────────────
@ -21,7 +22,8 @@ function charButton(c: Character, options?: { shared?: boolean }): string {
function char(c: Character, options?: CharDisplayOptions): string { function char(c: Character, options?: CharDisplayOptions): string {
const showEmoji = options?.emoji ?? true; const showEmoji = options?.emoji ?? true;
const showLevel = options?.level ?? true; const showLevel = options?.level ?? true;
const classKey = c.class.key; const classKey = typeof c.class === "object" ? c.class?.key : c.class;
if (!classKey) return `${c.level} ${c.name}`; // fallback if no class
const classStr = showEmoji ? (Emoji.class(classKey) || classKey) : classKey; const classStr = showEmoji ? (Emoji.class(classKey) || classKey) : classKey;
const levelStr = showLevel ? `${c.level} ` : ""; const levelStr = showLevel ? `${c.level} ` : "";
return `${classStr} ${levelStr}${c.name}`.trim(); return `${classStr} ${levelStr}${c.name}`.trim();
@ -130,6 +132,114 @@ function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string {
return `${dash} (${dash}${zero})`; // "— ( — 0 )" when others have delta 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 ──────────────────────────────────────────────────────── // ─── Bringer formatters ────────────────────────────────────────────────────────
function bringerDisplay(n: Nation): string { function bringerDisplay(n: Nation): string {
@ -148,11 +258,49 @@ export const format = {
nation, nation,
score, score,
emoji, emoji,
date,
number: {
abbrev: abbrev,
colored: colorNumber
},
wrank: { wrank: {
rank: wrankRank, rank: wrankRank,
delta: wrankDelta, delta: wrankDelta,
full: wrankFull, full: wrankFull,
noRank: wrankNoRank, noRank: wrankNoRank,
row(entry: WRankEntry|null, tgGoal: number, context: { nationHasRank: boolean; nationHasDelta: boolean }): string {
if (!entry || entry.currentRank === 0) {
if (!context.nationHasRank) return "";
return format.wrank.noRank({ delta: context.nationHasDelta });
}
const needsHolder = entry.previousRank === undefined && context.nationHasDelta;
return format.wrank.rank(entry, tgGoal) +
format.wrank.delta(entry, { brackets: true, placeholder: needsHolder });
},
}, },
scoreBold(pts: number): string {
return `**${pts}**`;
},
scoreEmoji(pts: number): string {
return String(pts).split("").map((d) => Emoji.get(`wrank_${d}`) || d).join("");
},
/**
* Pad a name with invisible Hangul filler characters () to a target
* character length, for approximate column alignment in proportional fonts.
* EXPERIMENTAL alignment depends on Discord's font rendering and varies
* by character width (e.g. »« symbols aren't the same width as letters).
*/
padName(name: string, targetLength: number): string {
const FILLER = "";
const diff = targetLength - name.length;
return diff > 0 ? name + FILLER.repeat(diff) : name;
},
scoreSubmitLine,
statText,
tgCount: formatTGCount,
stats: formatStats,
kd: formatKd,
bringer: bringerDisplay, bringer: bringerDisplay,
}; };

View file

@ -4,43 +4,51 @@ import { TGResult, TGScore, Nation } from "../types";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
import { TGKey } from "@systems/tg-key";
import { Logger } from "@systems/logger";
const log = Logger.for("upsert-score");
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history"); 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 { function historyPath(key: string): string {
return path.join(HISTORY_DIR, `${key}.json`); return path.join(HISTORY_DIR, `${key}.json`);
} }
export function loadResult(date: string, slot: number): TGResult | null { 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 { export function saveResult(result: TGResult): void {
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true }); 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 { export function upsertScore(score: TGScore): void {
const result = loadResult(score.date, score.slot) ?? { let result = loadResult(score.date, score.slot);
slot: score.slot, if (!result || !result.date || !result.slot) {
date: score.date, result = {
confirmed: false, slot: score.slot,
nationKD: { date: score.date,
source: Nation.Procyon, confirmed: false,
capella: { k: 0, d: 0 }, nationKD: {
procyon: { k: 0, d: 0 }, source: Nation.Procyon,
}, capella: { k: 0, d: 0 },
scores: [], procyon: { k: 0, d: 0 },
}; },
scores: [],
};
}
// Overwrite existing score for this player+slot // Overwrite existing score for this player+slot
result.scores = result.scores.filter( result.scores = result.scores.filter(
(s) => !(s.userKey === score.userKey && s.characterName === score.characterName && s.slot === score.slot && s.date === score.date) (s) => !(s.userKey === score.userKey && s.characterName === score.characterName && s.slot === score.slot && s.date === score.date)
); );
result.scores.push(score); result.scores.push(score);
log.debug(`upsertScore: about to save — result.date=${result.date} result.slot=${result.slot}`);
saveResult(result); 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 }) * Leaves.formatIndicator({ characterName })
*/ */
import { UserKey, CharName, HistoryKey } from "@types"; import { UserKey, CharName } from "@types";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { Runtime } from "@systems/runtime"; import { Runtime } from "@systems/runtime";
import { TGKey } from "@systems/tg-key";
Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" }); Runtime.phase("load", () => Leaves.load(), { name: "Leaves.load" });
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface LeaveRecord { interface LeaveRecord {
historyKey: HistoryKey; historyKey: TGKey;
markedBy: UserKey; markedBy: UserKey;
markedAt: string; markedAt: string;
} }
@ -60,7 +61,7 @@
mark({ characterName, ownerKey, historyKey, markedBy }: { mark({ characterName, ownerKey, historyKey, markedBy }: {
characterName: CharName; characterName: CharName;
ownerKey: UserKey; ownerKey: UserKey;
historyKey: HistoryKey; historyKey: TGKey;
markedBy: UserKey; markedBy: UserKey;
}): void { }): void {
if (!_data[characterName]) { if (!_data[characterName]) {
@ -75,7 +76,7 @@
unmark({ characterName, historyKey }: { unmark({ characterName, historyKey }: {
characterName: CharName; characterName: CharName;
historyKey: HistoryKey; historyKey: TGKey;
}): void { }): void {
if (!_data[characterName]) return; if (!_data[characterName]) return;
_data[characterName].history = _data[characterName].history.filter( _data[characterName].history = _data[characterName].history.filter(
@ -87,7 +88,7 @@
hasLeft({ characterName, historyKey }: { hasLeft({ characterName, historyKey }: {
characterName: CharName; characterName: CharName;
historyKey: HistoryKey; historyKey: TGKey;
}): boolean { }): boolean {
return _data[characterName]?.history.some((r) => r.historyKey === historyKey) ?? false; 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 fs from "fs";
import path from "path";
import { Character, UserKey, CharName } from "@types"; import { Character, UserKey, CharName } from "@types";
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
@ -35,16 +34,16 @@
* Find a character by name across all users. * Find a character by name across all users.
* Character names are unique across users (enforced by conflict system). * Character names are unique across users (enforced by conflict system).
*/ */
find(charName: CharName): Character | null { find(charName: CharName): Character | null {
const chars = loadChars(); const chars = loadChars();
for (const data of Object.values(chars)) { for (const [ownerKey, data] of Object.entries(chars)) {
const found = data.characters?.find( const found = data.characters?.find(
(c) => c.name.toLowerCase() === charName.toLowerCase() (c) => c.name.toLowerCase() === charName.toLowerCase()
); );
if (found) return found; if (found) return { ...found, ownerKey } as Character;
} }
return null; return null;
}, },
all(): Character[] { all(): Character[] {
const chars = loadChars(); const chars = loadChars();
@ -54,20 +53,21 @@
/** /**
* Find a character by name for a specific user. * Find a character by name for a specific user.
*/ */
findForUser(userKey: UserKey, charName: CharName): Character | null { findForUser(userKey: UserKey, charName: CharName): Character | null {
const chars = loadChars(); const chars = loadChars();
return chars[userKey]?.characters?.find( const found = chars[userKey]?.characters?.find(
(c) => c.name.toLowerCase() === charName.toLowerCase() (c) => c.name.toLowerCase() === charName.toLowerCase()
) ?? null; );
}, return found ? { ...found, ownerKey: userKey } as Character : null;
},
/** /**
* Get all characters for a user. * Get all characters for a user.
*/ */
forUser(userKey: UserKey): Character[] { forUser(userKey: UserKey): Character[] {
const chars = loadChars(); const chars = loadChars();
return chars[userKey]?.characters ?? []; return (chars[userKey]?.characters ?? []).map((c) => ({ ...c, ownerKey: userKey } as Character));
}, },
/** /**
* Get the active character for a user (from characters.json only, no borrow). * Get the active character for a user (from characters.json only, no borrow).
@ -87,7 +87,7 @@
if (ownerKey === userKey) continue; if (ownerKey === userKey) continue;
for (const char of data.characters ?? []) { for (const char of data.characters ?? []) {
if (char.sharedWith?.includes(userKey)) { if (char.sharedWith?.includes(userKey)) {
result.push({ char, ownerKey }); result.push({ char: { ...char, ownerKey } as Character, ownerKey });
} }
} }
} }

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

View file

@ -2,6 +2,9 @@ import { TGScore, Nation, ClassKey } from "../types";
import { Config } from "./config"; import { Config } from "./config";
import { upsertScore, todayString } from "./history"; import { upsertScore, todayString } from "./history";
import { WRank } from "./wrank"; 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 // Normalize a slot string to a 24h integer hour
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" // Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
@ -33,10 +36,10 @@ export function normalizeSlot(input: string): number | null {
// Detect which slot a submission belongs to based on current time // Detect which slot a submission belongs to based on current time
export function detectSlot(): number | null { export function detectSlot(): number | null {
const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active);
const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000; const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000;
const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000; const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000;
const now = Date.now(); const now = Date.now();
for (const slot of slots) { for (const slot of slots) {
const today = new Date(); const today = new Date();
@ -71,7 +74,9 @@ export interface ScoreSubmission {
export function submitScore(sub: ScoreSubmission): void { export function submitScore(sub: ScoreSubmission): void {
const date = sub.date ?? todayString(); 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 = { const score: TGScore = {
userKey: sub.userKey, userKey: sub.userKey,
@ -82,15 +87,16 @@ export function submitScore(sub: ScoreSubmission): void {
pts: sub.pts, pts: sub.pts,
k: sub.k, k: sub.k,
d: sub.d, d: sub.d,
atk: sub.atk, stats: sub.atk !== undefined || sub.def !== undefined || sub.heal !== undefined
def: sub.def, ? { atk: sub.atk, def: sub.def, heal: sub.heal }
heal: sub.heal, : undefined,
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
slot: sub.slot, slot: sub.slot,
date, date,
submittedByOfficer: sub.submittedByOfficer, submittedByOfficer: sub.submittedByOfficer,
}; };
log.debug(`score.date=${score.date} score.slot=${score.slot}`);
upsertScore(score); upsertScore(score);
WRank.recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); 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 }) * 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 { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Score, TGScore, WeeklySummary } from "@systems/score"; import { Score, WeeklySummary } from "@systems/score";
import { Attendance } from "@systems/attendance"; import { Attendance } from "@systems/attendance";
import { Nations } from "@systems/nations";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
import { TGKey } from "@systems/tg-key";
export const TG = { export const TG = {
// ── Week ────────────────────────────────────────────────────────────────── // ── Week ──────────────────────────────────────────────────────────────────
@ -66,7 +66,7 @@
// ── Attendance ──────────────────────────────────────────────────────────── // ── Attendance ────────────────────────────────────────────────────────────
getAttendance({ historyKey, nation }: { getAttendance({ historyKey, nation }: {
historyKey: HistoryKey; historyKey: TGKey;
nation?: Nation; nation?: Nation;
}): UserKey[] { }): UserKey[] {
const players = Attendance.players(historyKey); const players = Attendance.players(historyKey);
@ -75,7 +75,7 @@
return players; return players;
}, },
allSubmitted(historyKey: HistoryKey): boolean { allSubmitted(historyKey: TGKey): boolean {
return Attendance.allSubmitted(historyKey); return Attendance.allSubmitted(historyKey);
}, },
@ -86,7 +86,7 @@
slot: SlotHour; slot: SlotHour;
date?: string; date?: string;
}): TGScore | null { }): TGScore | null {
return Score.get({ character, slot, date }); return Score.get({ character, slot });
}, },
getWeeklySummary({ character }: { character: Character }): WeeklySummary { getWeeklySummary({ character }: { character: Character }): WeeklySummary {

View file

@ -23,6 +23,8 @@
import { Nation, VoteEntry, PollState } from "@types"; import { Nation, VoteEntry, PollState } from "@types";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Leaves } from "@systems/leaves"; import { Leaves } from "@systems/leaves";
import { PersistentMessage } from "@systems/persistent-message";
import { TGKey } from "@systems/tg-key";
const log = Logger.for("updates"); const log = Logger.for("updates");
@ -78,7 +80,7 @@
}[]; }[];
leaves?: { leaves?: {
characterName: string; characterName: string;
historyKey: string; historyKey: TGKey;
}[]; }[];
} }
@ -96,19 +98,6 @@
return path.join(updatesDir(), ".message-ids.json"); 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 ─────────────────────────────────────────────────────────── // ─── Versions cache ───────────────────────────────────────────────────────────
let _versionsCache: string[] | null = null; let _versionsCache: string[] | null = null;
@ -134,10 +123,13 @@
for (const section of entry.sections) { for (const section of entry.sections) {
lines.push(`${section.emoji} **${section.label}**`); lines.push(`${section.emoji} **${section.label}**`);
for (const item of section.items) { for (const item of section.items) {
const emojiStr = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•"; 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)
lines.push(`${emojiStr} ${resolvedText}`); const resolvedText = item.text.includes("<:") && item.text.includes(":>")
} ? Emoji.resolveTokens(item.text)
: item.text;
lines.push(`${emojiStr} ${resolvedText}`);
}
lines.push(""); lines.push("");
} }
@ -232,7 +224,10 @@
try { try {
PollUI.setLayout(example.layout); PollUI.setLayout(example.layout);
const state = buildExamplePollState(exampleData); 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}`); exampleEmbed.setTitle(`📋 Example — ${example.caption}`);
embeds.push(exampleEmbed); embeds.push(exampleEmbed);
} finally { } finally {
@ -254,7 +249,7 @@
const channel = await client.channels.fetch(channelId) as TextChannel; const channel = await client.channels.fetch(channelId) as TextChannel;
const embeds = Updates.buildEmbeds(entry); 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()}`); log.debug(`post: version=${version} messageId=${messageId} idsPath=${messageIdsPath()}`);
@ -266,11 +261,12 @@
return; return;
} catch { } catch {
log.warn(`Could not edit ${messageId}, posting new`); log.warn(`Could not edit ${messageId}, posting new`);
PersistentMessage.delete({ store: "updates", key: version });
} }
} }
const msg = await channel.send({ embeds }); 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})`); 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 { Config } from "@systems/config";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { TGKey } from "@systems/tg-key";
import { Runtime } from "@systems/runtime"; import { Runtime } from "@systems/runtime";
import { Logger } from "@systems/logger"; import { Logger } from "@systems/logger";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
@ -40,7 +41,7 @@ export interface WRankEntry {
export interface WRankWeek { export interface WRankWeek {
weekKey: string; weekKey: string;
entries: Record<Nation, SerializableWRankEntry[]>; entries: Record<Nation, SerializableWRankEntry[]>;
scoreIndex: Record<CharName, HistoryKey[]>; scoreIndex: Record<CharName, TGKey[]>;
bringer: { bringer: {
[Nation.Capella]: string | null; [Nation.Capella]: string | null;
[Nation.Procyon]: string | null; [Nation.Procyon]: string | null;
@ -138,6 +139,10 @@ export const WRank = {
return _data[weekKey] ?? null; return _data[weekKey] ?? null;
}, },
allWeeks(): WRankData {
return _data;
},
// ── Score recording ────────────────────────────────────────────────────────── // ── Score recording ──────────────────────────────────────────────────────────
recordScore( recordScore(
@ -146,7 +151,7 @@ export const WRank = {
cls: ClassKey, cls: ClassKey,
nation: Nation, nation: Nation,
pts: number, pts: number,
historyKey: HistoryKey historyKey: TGKey
): void { ): void {
const week = ensureWeek(WRank.weekKey()); const week = ensureWeek(WRank.weekKey());
const list = week.entries[nation]; const list = week.entries[nation];
@ -187,16 +192,16 @@ export const WRank = {
// ── Entry lookup ───────────────────────────────────────────────────────────── // ── Entry lookup ─────────────────────────────────────────────────────────────
entry(characterName: CharName, nation: Nation): WRankEntry | null { entry(characterName: CharName, nation: Nation, weekKey?: string): WRankEntry | null {
const week = WRank.currentWeek(); const week = weekKey ? (_data[weekKey] ?? null) : WRank.currentWeek();
const list = week.entries[nation]; const list = week.entries[nation];
const raw = list.find((e) => e.characterName === characterName); const raw = list.find((e) => e.characterName === characterName);
return raw ? hydrateEntry(raw) : null; return raw ? hydrateEntry(raw) : null;
}, },
entriesForNation(nation: Nation): WRankEntry[] { entriesForNation(nation: Nation, week?: WRankWeek): WRankEntry[] {
const week = WRank.currentWeek(); const _week = week ?? WRank.currentWeek();
return week.entries[nation].map(hydrateEntry); return _week.entries[nation].map(hydrateEntry);
}, },
// ── Snapshot ───────────────────────────────────────────────────────────────── // ── Snapshot ─────────────────────────────────────────────────────────────────

View file

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

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

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

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

@ -0,0 +1,126 @@
/**
* Layout shared domain-aware formatting for all embed types.
* Wraps format.ts functions with business logic (Config, Bringer, Leaves).
*
* Usage:
* import { Layout } from "@ui/layout";
*
* Layout.wrank(entry, goal, context)
* Layout.tgCount(done, goal)
* Layout.kd(k, d)
* Layout.bringer(char)
* Layout.cockroach(char, historyKey)
* Layout.indicators(char, { historyKey })
* Layout.formatRow(template, tokens)
*/
import { Character, Nation } from "@types";
import { WRankEntry, 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,47 +2,31 @@
* BaseLayout shared poll layout functions. * BaseLayout shared poll layout functions.
* All layouts inherit these via BaseLayout.methods() spread. * All layouts inherit these via BaseLayout.methods() spread.
* Override only what differs in each layout. * 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 { Config } from "@systems/config";
import { WRank, WRankEntry } from "@systems/wrank"; import { WRank, WRankEntry } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Leaves } from "@systems/leaves"; import { Leaves } from "@systems/leaves";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { TGKey } from "@systems/tg-key";
import { format } from "@format"; import { format } from "@format";
import { PollRowContext } from "@ui/types"; import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
// ─── W.Rank formatting ──────────────────────────────────────────────────────── // ─── W.Rank formatting ────────────────────────────────────────────────────────
export function formatWRank( export function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
wRankEntry: WRankEntry | null, const tgGoal = Config.get({ section: "wrank", key: "goal" });
context: PollRowContext return format.wrank.row(wRankEntry, tgGoal, context);
): string {
if (!wRankEntry || wRankEntry.currentRank === 0) {
if (!context.nationHasRank) return "";
return format.wrank.noRank({ delta: context.nationHasDelta });
}
const goal = Config.get({ section: "wrank", key: "goal" });
const needsHolder = wRankEntry.previousRank === undefined && context.nationHasDelta;
return format.wrank.rank(wRankEntry, goal) +
format.wrank.delta(wRankEntry, { brackets: true, placeholder: needsHolder });
} }
// ─── Row formatting ─────────────────────────────────────────────────────────── // ─── Row formatting ───────────────────────────────────────────────────────────
export function formatRow(entry: VoteEntry, context: PollRowContext): string { export function formatRow(entry: VoteEntry, context: PollRowContext): string {
const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
const nation = entry.characterNation; const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation const wRankEntry = entry.characterName && entry.characterNation
? WRank.entry(entry.characterName, entry.characterNation) ? WRank.entry(entry.characterName, entry.characterNation)
@ -76,8 +60,8 @@
// Nation emoji prefix // Nation emoji prefix
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`; if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
// Cockroach indicator (left TG) // Cockroach indicator
if (entry.userKey && entry.characterName && context.historyKey) { if (entry.characterName && context.historyKey) {
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) { if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`; row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
} }
@ -91,7 +75,7 @@
export function buildContext( export function buildContext(
entries: VoteEntry[], entries: VoteEntry[],
nation: Nation, nation: Nation,
options?: { showNationEmoji?: boolean; historyKey?: string } options?: { showNationEmoji?: boolean; historyKey?: TGKey }
): PollRowContext { ): PollRowContext {
const nationHasRank = entries.some((e) => { const nationHasRank = entries.some((e) => {
if (!e.characterName) return false; 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( export function formatMessages(
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
@ -129,34 +131,16 @@
.join("\n"); .join("\n");
} }
// ─── Embed helpers ──────────────────────────────────────────────────────────── 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 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: any): number { export function resolveTitle(state: PollState, yesByNation: Record<Nation, VoteEntry[]>): string {
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 {
const capellaEmoji = Emoji.get("capella"); const capellaEmoji = Emoji.get("capella");
const procyonEmoji = Emoji.get("procyon"); const procyonEmoji = Emoji.get("procyon");
const counts = !state.locked && state.confirmed === null const counts = !state.locked && state.confirmed === null
@ -165,20 +149,23 @@
const statusSuffix = const statusSuffix =
state.locked ? " 🔒" : state.locked ? " 🔒" :
state.confirmed === "yes" ? " ✅" : state.confirmed === "yes" ? " ✅" :
state.confirmed === "no" ? " ❌" : ""; state.confirmed === "no" ? " ❌" :
(state as any).called ? " 🔔" : "";
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
} }
export function resolveFooter(state: 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 === "yes") return Config.get({ section: "poll", key: "confirmYes" });
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" }); if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
if ((state as any).called) return Config.get({ section: "poll", key: "calledMessage" }) ?? "🔔 TG was called.";
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
} }
export function buildYesByNation(state: any): { export function buildYesByNation(state: PollState): {
yesByNation: Record<Nation, VoteEntry[]>; yesByNation: Record<Nation, VoteEntry[]>;
noVoters: VoteEntry[]; noVoters: VoteEntry[];
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]; allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[];
} { } {
const yesByNation: Record<Nation, VoteEntry[]> = { const yesByNation: Record<Nation, VoteEntry[]> = {
@ -189,10 +176,7 @@
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
for (const entry of state.yes.values()) { for (const entry of state.yes.values()) {
const nation: Nation = entry.characterNation === Nation.Procyon const nation: Nation = (entry.characterNation as Nation) ?? Nation.Capella;
? Nation.Procyon
: Nation.Capella;
yesByNation[nation].push(entry); yesByNation[nation].push(entry);
allMessages.push({ entry, voteType: "yes" }); allMessages.push({ entry, voteType: "yes" });
} }
@ -204,13 +188,84 @@
return { yesByNation, noVoters, allMessages }; 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 ─────────────────────────────────────────────────────── // ─── BaseLayout factory ───────────────────────────────────────────────────────
export const BaseLayout = { export const BaseLayout = {
methods() { methods() {
return { return { formatRow, buildContext };
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 { PollLayout } from "@ui/types";
import { PollState, Nation, VoteEntry } from "@types"; import { BaseLayout, createBuildEmbed } from "../base-layout";
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;
}
export const defaultLayout: PollLayout = { export const defaultLayout: PollLayout = {
...BaseLayout.methods(), ...BaseLayout.methods(),
name: "default", name: "default",
description: "Standard vertical layout with nation-separated fields", 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 { PollLayout } from "@ui/types";
import { PollState, Nation } from "@types"; import { BaseLayout, createBuildEmbed } from "../base-layout";
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;
}
export const sideBySideLayout: PollLayout = { export const sideBySideLayout: PollLayout = {
...BaseLayout.methods(), ...BaseLayout.methods(),
name: "side-by-side", name: "side-by-side",
description: "Nations displayed inline side by side (auto-stacks if > 5 players per nation)", 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 { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation } from "@types"; import { PollState, VoteEntry, Nation } from "@types";
import { TGKey } from "@systems/tg-key";
// ─── Poll ───────────────────────────────────────────────────────────────────── // ─── Poll ─────────────────────────────────────────────────────────────────────
@ -11,12 +12,13 @@
nationHasRank: boolean; nationHasRank: boolean;
nationHasDelta: boolean; nationHasDelta: boolean;
showNationEmoji?: boolean; showNationEmoji?: boolean;
historyKey?: string historyKey?: TGKey;
} }
export interface PollEmbedOptions { export interface PollEmbedOptions {
overrideLockMsg?: string; overrideLockMsg?: string;
showScoreButton?: boolean; showScoreButton?: boolean;
historyKey?: TGKey;
} }
export interface PollLayout { export interface PollLayout {

View file

@ -38,7 +38,10 @@
"@ui/poll": ["src/ui/poll/index"], "@ui/poll": ["src/ui/poll/index"],
"@ui/types": ["src/ui/types"], "@ui/types": ["src/ui/types"],
"@discord": ["src/discord/index"], "@discord": ["src/discord/index"],
"@discord/*": ["src/discord/*"] "@discord/*": ["src/discord/*"],
"@ui/layout": ["src/ui/layout"],
"@ui/result": ["src/ui/result/index"],
"@ui/leaderboard": ["src/ui/leaderboard/index"]
} }
}, },
"include": ["src/**/*", "scripts/**/*"], "include": ["src/**/*", "scripts/**/*"],