- 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.
133 lines
No EOL
4.1 KiB
TypeScript
133 lines
No EOL
4.1 KiB
TypeScript
/**
|
|
* 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,
|
|
}; |