tg-bot-ts/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts

155 lines
No EOL
7.9 KiB
TypeScript

/**
* 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,
};