diff --git a/data/emojis/misc.json b/data/emojis/misc.json index 3e2b8cd..87c564f 100644 --- a/data/emojis/misc.json +++ b/data/emojis/misc.json @@ -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>" } \ No newline at end of file diff --git a/data/emojis/wrank-down.json b/data/emojis/wrank-down.json index abb6fc3..89f98ad 100644 --- a/data/emojis/wrank-down.json +++ b/data/emojis/wrank-down.json @@ -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>" } \ No newline at end of file diff --git a/data/emojis/wrank-up.json b/data/emojis/wrank-up.json index 139554d..f26700e 100644 --- a/data/emojis/wrank-up.json +++ b/data/emojis/wrank-up.json @@ -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>" } \ No newline at end of file diff --git a/data/updates/v0.9.2/update.json b/data/updates/v0.9.2/update.json new file mode 100644 index 0000000..dd83355 --- /dev/null +++ b/data/updates/v0.9.2/update.json @@ -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": [] +} \ No newline at end of file diff --git a/font/gg sans Regular.ttf b/font/gg sans Regular.ttf new file mode 100644 index 0000000..745d2ec Binary files /dev/null and b/font/gg sans Regular.ttf differ diff --git a/messages/emojis.json b/messages/emojis.json index 6af1d2c..98c17b2 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -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>" } \ No newline at end of file diff --git a/src/commands/tgAdmin.ts b/src/commands/tgAdmin.ts index 3d76ef3..d10b537 100644 --- a/src/commands/tgAdmin.ts +++ b/src/commands/tgAdmin.ts @@ -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); } \ No newline at end of file diff --git a/src/handlers/modals.ts b/src/handlers/modals.ts index 495e337..28e2385 100644 --- a/src/handlers/modals.ts +++ b/src/handlers/modals.ts @@ -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: โ€” 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().addComponents(ptsInput), new ActionRowBuilder().addComponents(kdInput), - new ActionRowBuilder().addComponents(atkDefInput), - new ActionRowBuilder().addComponents(healInput), + new ActionRowBuilder().addComponents(atkInput), + new ActionRowBuilder().addComponents(defInput), + ...(showHeal ? [new ActionRowBuilder().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}` : ""}` + ); } } \ No newline at end of file diff --git a/src/subcommands/admin/reset-week.ts b/src/subcommands/admin/reset-week.ts new file mode 100644 index 0000000..38337ca --- /dev/null +++ b/src/subcommands/admin/reset-week.ts @@ -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 { + 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, +}; \ No newline at end of file diff --git a/src/systems/bringer.ts b/src/systems/bringer.ts index 0fce385..3aae63c 100644 --- a/src/systems/bringer.ts +++ b/src/systems/bringer.ts @@ -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; - } - - WRank.save(); - }, + update({ week }: { week?: WRankWeek }): void { + const goal = Config.get({ section: "wrank", key: "goal" }); + const _week = week ?? WRank.currentWeek(); + + 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(); + }, }; \ No newline at end of file diff --git a/src/systems/score.ts b/src/systems/score.ts index d49162b..932f3b5 100644 --- a/src/systems/score.ts +++ b/src/systems/score.ts @@ -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; diff --git a/src/systems/tg.ts b/src/systems/tg.ts index 2510cf7..85b9b6c 100644 --- a/src/systems/tg.ts +++ b/src/systems/tg.ts @@ -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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index bbb10eb..348fa7d 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -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) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/ui/result/layouts/sequential-arrow.ts b/src/ui/result/layouts/sequential-arrow.ts new file mode 100644 index 0000000..0cfe887 --- /dev/null +++ b/src/ui/result/layouts/sequential-arrow.ts @@ -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 = { + 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, + }; \ No newline at end of file diff --git a/src/ui/result/layouts/sequential.ts b/src/ui/result/layouts/sequential.ts index 2d5fa78..99d5a8a 100644 --- a/src/ui/result/layouts/sequential.ts +++ b/src/ui/result/layouts/sequential.ts @@ -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"; - - const TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}"; + import { WRankWeek } from "@root/src/systems/wrank"; + import { Logger } from "@root/src/systems/logger"; + + 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 paddedBlock = TextAlign.padToMax(nameBlock, allNameBlocks); - const cockroach = Layout.cockroach(char as any, row.historyKey); + 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 tokens: Record = { 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}`; } diff --git a/src/ui/text-align.ts b/src/ui/text-align.ts index c4561f1..647ce60 100644 --- a/src/ui/text-align.ts +++ b/src/ui/text-align.ts @@ -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); },