- 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.
271 lines
No EOL
11 KiB
TypeScript
271 lines
No EOL
11 KiB
TypeScript
/**
|
|
* BaseLayout — shared poll layout functions.
|
|
* All layouts inherit these via BaseLayout.methods() spread.
|
|
* Override only what differs in each layout.
|
|
*/
|
|
|
|
import { EmbedBuilder } from "discord.js";
|
|
import { VoteEntry, Nation, PollState } from "@types";
|
|
import { Config } from "@systems/config";
|
|
import { WRank, WRankEntry } from "@systems/wrank";
|
|
import { Bringer } from "@systems/bringer";
|
|
import { Leaves } from "@systems/leaves";
|
|
import { Emoji } from "@systems/emojis";
|
|
import { TGKey } from "@systems/tg-key";
|
|
import { format } from "@format";
|
|
import { PollLayout, PollRowContext, PollEmbedOptions } from "@ui/types";
|
|
|
|
// ─── W.Rank formatting ────────────────────────────────────────────────────────
|
|
|
|
export function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
|
|
const tgGoal = Config.get({ section: "wrank", key: "goal" });
|
|
return format.wrank.row(wRankEntry, tgGoal, context);
|
|
}
|
|
|
|
// ─── Row formatting ───────────────────────────────────────────────────────────
|
|
|
|
export 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();
|
|
|
|
// Bringer indicator
|
|
if (nation && entry.userKey) {
|
|
const bringer = Bringer.get({ nation });
|
|
if (bringer && bringer === entry.characterName) {
|
|
row += ` · ${format.bringer(nation)}`;
|
|
}
|
|
}
|
|
|
|
// Shared character indicator
|
|
if (entry.borrowedFrom) row += ` ${Emoji.get("borrowed") || "🔗"}`;
|
|
|
|
// Nation emoji prefix
|
|
if (context.showNationEmoji && nation) row = `${Emoji.nation(nation)} ${row}`;
|
|
|
|
// Cockroach indicator
|
|
if (entry.characterName && context.historyKey) {
|
|
if (Leaves.hasLeft({ characterName: entry.characterName, historyKey: context.historyKey })) {
|
|
row += ` ${Leaves.formatIndicator({ characterName: entry.characterName })}`;
|
|
}
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
// ─── Context building ─────────────────────────────────────────────────────────
|
|
|
|
export function buildContext(
|
|
entries: VoteEntry[],
|
|
nation: Nation,
|
|
options?: { showNationEmoji?: boolean; historyKey?: TGKey }
|
|
): PollRowContext {
|
|
const nationHasRank = entries.some((e) => {
|
|
if (!e.characterName) return false;
|
|
const wr = WRank.entry(e.characterName, nation);
|
|
return wr !== null && wr.currentRank !== 0;
|
|
});
|
|
|
|
const nationHasDelta = entries.some((e) => {
|
|
if (!e.characterName) return false;
|
|
const wr = WRank.entry(e.characterName, nation);
|
|
return wr?.previousRank !== undefined;
|
|
});
|
|
|
|
return {
|
|
nationHasRank,
|
|
nationHasDelta,
|
|
showNationEmoji: options?.showNationEmoji ?? false,
|
|
historyKey: options?.historyKey,
|
|
};
|
|
}
|
|
|
|
// ─── Shared embed helpers ─────────────────────────────────────────────────────
|
|
|
|
export function formatNationField(
|
|
nation: Nation,
|
|
yesEntries: VoteEntry[],
|
|
noVoters: VoteEntry[],
|
|
showNoInline: boolean,
|
|
historyKey?: TGKey
|
|
): string {
|
|
const context = buildContext(yesEntries, nation, { historyKey });
|
|
const noEntries = showNoInline
|
|
? noVoters.filter((e) => e.characterNation === nation)
|
|
: [];
|
|
const lines = [
|
|
...yesEntries.map((e) => formatRow(e, context)),
|
|
...noEntries.map((e) => `❌ ${formatRow(e, context)}`),
|
|
];
|
|
return lines.length > 0 ? lines.join("\n") : "—";
|
|
}
|
|
|
|
export function formatMessages(
|
|
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[]
|
|
): 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");
|
|
}
|
|
|
|
export function resolveColor(state: PollState): number {
|
|
if (state.confirmed === "yes") return 0x57f287; // green
|
|
if (state.confirmed === "no") return 0xed4245; // red
|
|
if ((state as any).called) return 0xe8a317; // orange
|
|
if (state.locked) return 0x888888; // grey
|
|
return 0xe8a317; // orange (open)
|
|
}
|
|
|
|
|
|
export function resolveTitle(state: PollState, yesByNation: Record<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" ? " ❌" :
|
|
(state as any).called ? " 🔔" : "";
|
|
|
|
return `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
|
|
}
|
|
|
|
export function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
|
|
if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
|
|
if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
|
|
if ((state as any).called) return Config.get({ section: "poll", key: "calledMessage" }) ?? "🔔 TG was called.";
|
|
if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
|
|
return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
|
|
}
|
|
|
|
export function buildYesByNation(state: PollState): {
|
|
yesByNation: Record<Nation, VoteEntry[]>;
|
|
noVoters: VoteEntry[];
|
|
allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[];
|
|
} {
|
|
const yesByNation: Record<Nation, VoteEntry[]> = {
|
|
[Nation.Capella]: [],
|
|
[Nation.Procyon]: [],
|
|
};
|
|
const noVoters: VoteEntry[] = [];
|
|
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
|
|
|
|
for (const entry of state.yes.values()) {
|
|
const nation: Nation = (entry.characterNation as Nation) ?? 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" });
|
|
}
|
|
|
|
return { yesByNation, noVoters, allMessages };
|
|
}
|
|
|
|
// ─── buildEmbed factory ───────────────────────────────────────────────────────
|
|
|
|
export function createBuildEmbed({ inline, maxInlinePlayers = 5 }: {
|
|
inline: boolean;
|
|
maxInlinePlayers?: number;
|
|
}) {
|
|
return function buildEmbed(state: PollState, options?: PollEmbedOptions): EmbedBuilder {
|
|
const { yesByNation, noVoters, allMessages } = buildYesByNation(state);
|
|
const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" });
|
|
const historyKey = options?.historyKey
|
|
?? TGKey.current({ slot: state.slot });
|
|
|
|
const capellaEmoji = Emoji.get("capella");
|
|
const procyonEmoji = Emoji.get("procyon");
|
|
|
|
// Auto-stack to vertical if too many players
|
|
const maxPlayers = Math.max(
|
|
yesByNation[Nation.Capella].length,
|
|
yesByNation[Nation.Procyon].length
|
|
);
|
|
const useInline = inline && maxPlayers <= maxInlinePlayers;
|
|
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(resolveTitle(state, yesByNation))
|
|
.setColor(resolveColor(state))
|
|
.setTimestamp();
|
|
|
|
const imageUrl = (state as any).called
|
|
? Config.get({ section: "poll", key: "calledGameImageUrl" })
|
|
: state.confirmed === "no"
|
|
? Config.get({ section: "poll", key: "cancelledImageUrl" })
|
|
: null;
|
|
|
|
if (imageUrl) embed.setImage(imageUrl);
|
|
|
|
if (useInline) {
|
|
// Side-by-side — no spacer needed
|
|
embed.addFields(
|
|
{
|
|
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
|
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
|
inline: true,
|
|
},
|
|
{
|
|
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
|
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
|
inline: true,
|
|
},
|
|
);
|
|
} else {
|
|
// Vertical — spacer between nations
|
|
embed.addFields(
|
|
{
|
|
name: `${capellaEmoji} Capella (${yesByNation[Nation.Capella].length})`,
|
|
value: formatNationField(Nation.Capella, yesByNation[Nation.Capella], noVoters, showNoInline, historyKey),
|
|
inline: false,
|
|
},
|
|
{ name: "\u200b", value: "\u200b", inline: false },
|
|
{
|
|
name: `${procyonEmoji} Procyon (${yesByNation[Nation.Procyon].length})`,
|
|
value: formatNationField(Nation.Procyon, yesByNation[Nation.Procyon], noVoters, showNoInline, historyKey),
|
|
inline: false,
|
|
},
|
|
);
|
|
}
|
|
|
|
const msgSection = formatMessages(allMessages);
|
|
if (msgSection) embed.addFields({ name: "\u200b", value: msgSection, inline: false });
|
|
|
|
embed.setFooter({ text: resolveFooter(state, noVoters.length, options?.overrideLockMsg) });
|
|
return embed;
|
|
};
|
|
}
|
|
|
|
// ─── BaseLayout factory ───────────────────────────────────────────────────────
|
|
|
|
export const BaseLayout = {
|
|
methods() {
|
|
return { formatRow, buildContext };
|
|
},
|
|
}; |