update layout, fix reset week

This commit is contained in:
Nuno Duque Nunes 2026-07-30 00:18:53 +01:00
parent 7d68530826
commit 2131b5674b
16 changed files with 340 additions and 118 deletions

View file

@ -12,5 +12,7 @@
"wrank_up": "<:wrank_up:1512114414474756132>",
"wrank_no_rank_delta": "<:wrank_no_rank_delta:1512263603519229982>",
"slash": "<:slash:1516648012422844416>",
"tg_flag": "<:tg_flag:1516946073392910336>"
"tg_flag": "<:tg_flag:1516946073392910336>",
"wrank_Q": "<:wrank_Q:1528508962717040740>",
"wrank_Q_gold": "<:wrank_Q_gold:1528508971978195088>"
}

View file

@ -99,5 +99,6 @@
"wrank_down_97": "<:wrank_down_97:1513360453152280709>",
"wrank_down_98": "<:wrank_down_98:1513360459758174328>",
"wrank_down_99": "<:wrank_down_99:1513360463558082590>",
"wrank_down_0": "<:wrank_down_0:1516648019297046709>"
"wrank_down_0": "<:wrank_down_0:1516648019297046709>",
"wrank_down_Q": "<:wrank_down_Q:1528508967658197144>"
}

View file

@ -99,5 +99,6 @@
"wrank_up_97": "<:wrank_up_97:1513360766588424192>",
"wrank_up_98": "<:wrank_up_98:1513360770895708303>",
"wrank_up_99": "<:wrank_up_99:1513360776071745576>",
"wrank_up_0": "<:wrank_up_0:1516648023084503113>"
"wrank_up_0": "<:wrank_up_0:1516648023084503113>",
"wrank_up_Q": "<:wrank_up_Q:1528508976185213072>"
}

View file

@ -0,0 +1,29 @@
{
"version": "v0.9.2",
"date": "2026-07-21",
"title": "Score Button Fix & W.Rank Reset Fix",
"layout": "default",
"sections": [
{
"type": "fix",
"label": "Fixes",
"emoji": "🔧",
"items": [
{ "text": "Fixed Submit Score button not updating the Leaderboard or auto-posting the TG Result" },
{ "text": "Fixed W.Rank weekly reset writing Bringer data into the wrong week and not creating the new week correctly" },
{ "text": "Fixed Bringer incorrectly being assigned to a player who met the TG count goal but was not W.Rank 1" },
{ "text": "Fixed weekly reset overwriting the previous week's Bringer data" }
]
},
{
"type": "improvement",
"label": "Improvements",
"emoji": "✨",
"items": [
{ "text": "Submit Score modal now has separate ATK and DEF fields" },
{ "text": "Heal field in the Submit Score modal only appears for healing classes (FA)" }
]
}
],
"examples": []
}

BIN
font/gg sans Regular.ttf Normal file

Binary file not shown.

View file

@ -274,5 +274,9 @@
"anima_atk": "<:anima_atk:1517702182710018179>",
"anima_def": "<:anima_def:1517700561468657785>",
"anima_massheal": "<:anima_massheal:1517702186434433146>",
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>"
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>",
"wrank_Q": "<:wrank_Q:1528508962717040740>",
"wrank_down_Q": "<:wrank_down_Q:1528508967658197144>",
"wrank_Q_gold": "<:wrank_Q_gold:1528508971978195088>",
"wrank_up_Q": "<:wrank_up_Q:1528508976185213072>"
}

View file

@ -9,6 +9,7 @@ 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 { ResetWeekCommands } from "../subcommands/admin/reset-week";
import { AnnouncementCommands } from "../subcommands/admin/announcement";
export function buildTgAdminCommand(): SlashCommandBuilder {
@ -167,6 +168,11 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
)
)
cmd.addSubcommand((s) => s
.setName("reset-week")
.setDescription("[Admin] Manually trigger the weekly W.Rank reset — for testing only")
)
cmd.addSubcommandGroup((g) => g
.setName("announcement")
.setDescription("Manage announcements")
@ -221,4 +227,5 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
if (group === "announcement" && sub === "list") return AnnouncementCommands.list(interaction);
if (group === null && sub === "test-align") return TestAlignCommands.handle(interaction);
if (group === null && sub === "reset-week") return ResetWeekCommands.handle(interaction);
}

View file

@ -5,28 +5,38 @@ import {
TextInputStyle,
ActionRowBuilder,
} from "discord.js";
import { Logger } from "@systems/logger";
import { Score } from "@systems/score";
import { Emoji } from "@systems/emojis";
import { resolveUser } from "@systems/users";
import { getEffectiveCharacter } from "@systems/borrow";
import { score } from "@subcommands/score/submitCore";
import { format } from "@format";
import { SlotHour, ClassKey } from "@root/src/types";
const log = Logger.for("modals");
// ─── Healing classes ──────────────────────────────────────────────────────────
// Only these classes see the Heal field in the score modal.
// Extend this array when new healing classes are added.
const HEALING_CLASSES: ClassKey[] = ["FA"];
function isHealingClass(cls: ClassKey): boolean {
return HEALING_CLASSES.includes(cls);
}
// ─── Modal IDs ────────────────────────────────────────────────────────────────
//
// score_submit:<slot> — score submission modal, slot baked into the customId
// so we don't need to pass state through the modal itself
export namespace modals {
// ─── Builder ───────────────────────────────────────────────────────────────
/**
* Build the score submission modal for a given userKey + slot.
* Title shows the active character so the user knows what they're submitting for.
*/
export function buildScoreModal(userKey: string, slot: number): ModalBuilder {
const { char } = getEffectiveCharacter(userKey);
// const charLabel = char ? format.char(char) : "your character";
const charLabel = char ? format.char(char, { emoji: false }) : "your character";
const classKey = (typeof char?.class === "object" ? (char.class as any)?.key : char?.class) as ClassKey | undefined;
const showHeal = !!classKey && isHealingClass(classKey);
const modal = new ModalBuilder()
.setCustomId(`score_submit:${slot}`)
@ -46,25 +56,36 @@ export namespace modals {
.setPlaceholder("5/2")
.setRequired(false);
const atkDefInput = new TextInputBuilder()
.setCustomId("atkdef")
.setLabel("ATK / DEF (e.g. 120/80)")
const atkInput = new TextInputBuilder()
.setCustomId("atk")
.setLabel("Attack Score")
.setStyle(TextInputStyle.Short)
.setPlaceholder("120/80")
.setPlaceholder("e.g. 120")
.setRequired(false);
const defInput = new TextInputBuilder()
.setCustomId("def")
.setLabel("Defense Score")
.setStyle(TextInputStyle.Short)
.setPlaceholder("e.g. 80")
.setRequired(false);
const healInput = new TextInputBuilder()
.setCustomId("heal")
.setLabel("Heal")
.setLabel("Healing Score")
.setStyle(TextInputStyle.Short)
.setPlaceholder("e.g. 500")
.setRequired(false);
// Base fields always shown: pts, k/d, atk, def (4 fields)
// Heal replaces def when class is a healer — keeps total at 4 or 5
// With heal: pts, k/d, atk, def, heal = 5 (Discord max)
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(ptsInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(atkDefInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(healInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(atkInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(defInput),
...(showHeal ? [new ActionRowBuilder<TextInputBuilder>().addComponents(healInput)] : []),
);
return modal;
@ -108,18 +129,35 @@ export namespace modals {
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
const user = await resolveUser(member);
const member = await interaction.guild!.members.fetch(interaction.user.id);
const user = await resolveUser(member);
if (!user.userKey) {
await interaction.editReply("❌ You are not registered in the system.");
return;
}
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
if (!char) {
await interaction.editReply("❌ No active character found. Use `/tg char set-active` first.");
return;
}
const classKey = (typeof char.class === "object" ? (char.class as any)?.key : char.class) as ClassKey;
const showHeal = isHealingClass(classKey);
// Parse fields
const ptsRaw = interaction.fields.getTextInputValue("pts");
const kdRaw = interaction.fields.getTextInputValue("kd") || null;
const atkDefRaw = interaction.fields.getTextInputValue("atkdef") || null;
const healRaw = interaction.fields.getTextInputValue("heal") || null;
const ptsRaw = interaction.fields.getTextInputValue("pts");
const kdRaw = interaction.fields.getTextInputValue("kd") || null;
const atkRaw = interaction.fields.getTextInputValue("atk") || null;
const defRaw = interaction.fields.getTextInputValue("def") || null;
// Heal field only exists in the modal for healing classes — safe to attempt,
// getTextInputValue returns "" for missing fields on some discord.js versions,
// so we guard with try/catch
let healRaw: string | null = null;
if (showHeal) {
try { healRaw = interaction.fields.getTextInputValue("heal") || null; }
catch { healRaw = null; }
}
const pts = parseInt(ptsRaw.trim(), 10);
if (isNaN(pts)) {
@ -127,30 +165,45 @@ export namespace modals {
return;
}
const kd = parseSlashPair(kdRaw);
const atkDef = parseSlashPair(atkDefRaw);
const heal = parseOptionalInt(healRaw);
const kd = parseSlashPair(kdRaw);
const atk = parseOptionalInt(atkRaw);
const def = parseOptionalInt(defRaw);
const heal = parseOptionalInt(healRaw);
if (kdRaw && !kd) {
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
return;
}
if (atkDefRaw && !atkDef) {
await interaction.editReply("❌ ATK/DEF must be in `atk/def` format, e.g. `120/80`.");
return;
}
const result = await score.submitForUser({
userKey: user.userKey,
log.debug(`Score submit via modal: userKey=${user.userKey} char=${char.name} slot=${slot} showHeal=${showHeal}`);
// ── Canonical path — same as /tg score set ────────────────────────────────
// Score.submit emits RuntimeEvents → Leaderboard.update + Result.post
await Score.submit({
character: char,
playedBy: borrowedFrom ? user.userKey : undefined,
pts,
slot,
k: kd?.[0],
d: kd?.[1],
atk: atkDef?.[0],
def: atkDef?.[1],
k: kd?.[0],
d: kd?.[1],
atk,
def,
heal,
slot: slot as SlotHour,
submittedByOfficer: false,
});
await interaction.editReply(result.message);
const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = Emoji.get("kd") || "⚔️";
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
const kdNote = kd ? `\n${kdEmoji} ${kd[0]}/${kd[1]}` : "";
const statsNote = [
atk !== undefined ? `ATK: ${atk}` : null,
def !== undefined ? `DEF: ${def}` : null,
heal !== undefined ? `HEAL: ${heal}` : null,
].filter(Boolean).join(" · ");
await interaction.editReply(
`${scoreEmoji} **${pts}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`
);
}
}

View file

@ -0,0 +1,19 @@
import { ChatInputCommandInteraction } from "discord.js";
import { TG } from "@systems/tg";
import { replyAndDelete } from "@utils";
import { User } from "@systems/users";
import { Config } from "@systems/config";
export async function handleResetWeek(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!User.hasOfficerRole({ member, officerRoles: Config.get({ section: "roles", key: "officer" }) })) {
return void replyAndDelete(interaction, "❌ Officer only.");
}
TG.resetWeek();
return void replyAndDelete(interaction, "✅ Week reset executed. Check logs for result.");
}
export const ResetWeekCommands = {
handle: handleResetWeek,
};

View file

@ -52,17 +52,15 @@
* Recompute the earned Bringer from scores.
* Called after weekly reset the top ranked player with >= goal TGs becomes Bringer.
*/
update({ week }: { week?: WRankWeek }): void {
const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) {
const _week = week ?? WRank.currentWeek();
const list = _week.entries[nation];
const qualified = list
.filter((e) => e.tgCount >= goal)
.sort((a, b) => a.currentRank - b.currentRank);
_week.bringer[nation] = qualified[0]?.characterName ?? null;
}
update({ week }: { week?: WRankWeek }): void {
const goal = Config.get({ section: "wrank", key: "goal" });
const _week = week ?? WRank.currentWeek();
WRank.save();
},
for (const nation of [Nation.Capella, Nation.Procyon]) {
const rank1 = _week.entries[nation].find((e) => e.currentRank === 1);
_week.bringer[nation] = (rank1 && rank1.tgCount >= goal) ? rank1.characterName : null;
}
WRank.save();
},
};

View file

@ -16,26 +16,6 @@
import { TGKey } from "@systems/tg-key";
import { RuntimeEvents } from "@systems/runtime";
// export interface TGScore {
// userKey: UserKey;
// playedBy?: UserKey; // if borrowed
// characterName: string;
// class: string;
// nation: Nation;
// pts: number;
// k?: number;
// d?: number;
// stats?: TGStats;
// submittedAt: string;
// slot: SlotHour;
// date: string;
// submittedByOfficer: boolean;
// wRankAtSubmission?: {
// rank: number;
// delta: number;
// };
// }
export interface WeeklySummary {
userKey: UserKey;
character: Character;

View file

@ -32,35 +32,7 @@
* Called every Monday 00:00 by the Scheduler.
*/
resetWeek(): void {
const now = new Date();
const newWeekKey = WRank.weekKey(now);
const prevWeekKey = WRank.weekKey(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000));
// Access internal data via WRank
const prevWeek = WRank.weekFromKey(prevWeekKey);
const newWeek = WRank.currentWeek(); // ensures new week exists
if (prevWeek) {
const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) {
const entries = prevWeek.entries[nation];
const rank1 = entries.find((e) => e.currentRank === 1);
// Rank 1 with >= goal TGs becomes Bringer — no exceptions
// Officers use Bringer.override() for manual adjustments
if (rank1 && rank1.tgCount >= goal) {
newWeek.bringer[nation] = rank1.characterName;
} else {
newWeek.bringer[nation] = null;
}
// Overrides do NOT carry forward — each week starts clean
delete (newWeek.bringer as any)[`${nation}Override`];
}
}
WRank.save();
console.log(`[TG] Week reset to ${newWeekKey}. Bringer:`, newWeek.bringer);
return WRank.resetWeek();
},
// ── Attendance ────────────────────────────────────────────────────────────

View file

@ -234,20 +234,25 @@ export const WRank = {
// ── Weekly reset ─────────────────────────────────────────────────────────────
resetWeek(): void {
const prevWeekKey = WRank.weekKey(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
// Fire the weekly reset on Monday 00:00 (WRank reset) (UTC+2 = Sunday 22:00)
const nowUtcNoon = new Date();
nowUtcNoon.setUTCHours(12, 0, 0, 0);
const newWeekKey = WRank.weekKey(nowUtcNoon);
const prevWeekKey = WRank.weekKey(new Date(nowUtcNoon.getTime() - 7 * 24 * 60 * 60 * 1000));
const prevWeek = _data[prevWeekKey];
const newWeek = ensureWeek(WRank.weekKey());
const newWeek = ensureWeek(newWeekKey);
if (prevWeek) {
Bringer.update({ week: prevWeek });
const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) {
const bringer = prevWeek.bringer[nation];
if (bringer) newWeek.bringer[nation] = bringer;
const rank1 = prevWeek.entries[nation].find((e) => e.currentRank === 1);
newWeek.bringer[nation] = (rank1 && rank1.tgCount >= goal) ? rank1.characterName : null;
}
}
WRank.save();
log.info(`Reset to ${WRank.weekKey()}.`);
log.info(`Week reset to ${newWeekKey}. Bringer: ${JSON.stringify(newWeek.bringer)}`);
},
// ── Bringer (legacy — use Bringer namespace directly) ────────────────────────

View file

@ -0,0 +1,143 @@
/**
* Sequential result layout with continuation line for secondary stats.
*
* Primary row: wrank · class · name[bringer] · score · k/d
* Secondary row (when stats exist): atk · def · heal
*
* The prefix is a custom emoji if configured, falling back to the
* Unicode arrow character. No column-locking attempt on the secondary
* row it starts with the arrow and lists stats cleanly left-to-right.
*/
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 { WRankWeek } from "@systems/wrank";
import { ResultLayout, ResultRow } from "../index";
const TEMPLATE = "{wrank} {class} {name}{indicators}{score} {kd}";
const KD_GAP = 4;
const SCORE_GAP = 4;
function arrowPrefix(): string {
return Emoji.get("arrow_continue") || "↳";
}
function formatRow(
row: ResultRow,
context: NationContext,
allNameBlocks: string[],
allScores: string[],
allKds: 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 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: `${TextAlign.gap(SCORE_GAP)}${scoreEmoji} ${TextAlign.padToMax(scoreText, allScores)}`,
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, allKds),
};
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 arrow = arrowPrefix();
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 statParts = [atkText, defText, healText].filter(Boolean);
const statsLine = `${TextAlign.gap(3)}${arrow} ${statParts.join(" ")}`;
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 BRINGER_GAP = 2;
const capellaNameBlocks = sortedCapella.map((r) => {
const tag = Layout.bringer(r.character as any, week ?? undefined);
if (!tag) return r.character.name;
return `${r.character.name}${TextAlign.gap(1)}${tag}${TextAlign.gap(BRINGER_GAP)}`;
});
const procyonNameBlocks = sortedProcyon.map((r) => {
const tag = Layout.bringer(r.character as any, week ?? undefined);
if (!tag) return r.character.name;
return `${r.character.name}${TextAlign.gap(1)}${tag}${TextAlign.gap(BRINGER_GAP)}`;
});
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 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, week));
const procyonFormatted = sortedProcyon.map((r) => formatRow(r, proContext, procyonNameBlocks, procyonScores, procyonKds, 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 sequentialArrowResultLayout: ResultLayout = {
name: "sequential-arrow",
description: "Name/score/K-D aligned, secondary stats on ↳ continuation line",
buildEmbed,
formatRow: formatRow as any,
};

View file

@ -15,9 +15,12 @@
import { EmbedHelpers } from "@ui/embed-helpers";
import { TextAlign } from "@ui/text-align";
import { ResultLayout, ResultRow } from "../index";
import { WRankWeek } from "@root/src/systems/wrank";
import { WRankWeek } from "@root/src/systems/wrank";
import { Logger } from "@root/src/systems/logger";
const TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}";
const log = Logger.for("result-layout/sequential");
const TEMPLATE = "{wrank} {class} {name}{indicators}{score} {kd}";
const KD_GAP = 4;
const SCORE_GAP = 4;
@ -47,16 +50,18 @@ import { WRankWeek } from "@root/src/systems/wrank";
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 nameBlock = bringerTag ? `${char.name}${TextAlign.gap(1)}${bringerTag}` : char.name;
const paddedName = TextAlign.padToMax(nameBlock, allNameBlocks);
const cockroach = Layout.cockroach(char as any, row.historyKey);
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)}`,
indicators: "", // already merged into name
score: `${TextAlign.gap(SCORE_GAP)}${scoreEmoji} ${TextAlign.padToMax(scoreText, scoreColumn)}`,
kd: TextAlign.gap(KD_GAP) + TextAlign.padToMax(kdText, kdColumn),
};
@ -71,8 +76,11 @@ import { WRankWeek } from "@root/src/systems/wrank";
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}`;
log.debug(`prefixText: "${prefixText}" estimatedWidth: ${TextAlign.estimateWidth(prefixText)}`);
log.debug(`prefixGap length: ${prefixGap.length} chars`);
const scoreEmojiWidth = TextAlign.estimateWidth(`${scoreEmoji} `);
const statsLine = `${prefixGap}${TextAlign.gap(SCORE_GAP)}${TextAlign.padLeft("", scoreEmojiWidth)}${TextAlign.padToMax(atkText, scoreColumn)} ${TextAlign.gap(DEF_GAP)}${TextAlign.padToMax(defText, kdColumn)} ${TextAlign.gap(HEAL_GAP)}${healText}`;
// 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}`;
}

View file

@ -123,7 +123,7 @@
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}`);
// 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);
},