update layout, fix reset week
This commit is contained in:
parent
7d68530826
commit
2131b5674b
16 changed files with 340 additions and 118 deletions
|
|
@ -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>"
|
||||
}
|
||||
|
|
@ -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>"
|
||||
}
|
||||
|
|
@ -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>"
|
||||
}
|
||||
29
data/updates/v0.9.2/update.json
Normal file
29
data/updates/v0.9.2/update.json
Normal 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
BIN
font/gg sans Regular.ttf
Normal file
Binary file not shown.
|
|
@ -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>"
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -115,11 +136,28 @@ export namespace modals {
|
|||
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 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)) {
|
||||
|
|
@ -128,29 +166,44 @@ export namespace modals {
|
|||
}
|
||||
|
||||
const kd = parseSlashPair(kdRaw);
|
||||
const atkDef = parseSlashPair(atkDefRaw);
|
||||
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],
|
||||
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}` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/subcommands/admin/reset-week.ts
Normal file
19
src/subcommands/admin/reset-week.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -54,13 +54,11 @@
|
|||
*/
|
||||
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;
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────
|
||||
|
|
|
|||
143
src/ui/result/layouts/sequential-arrow.ts
Normal file
143
src/ui/result/layouts/sequential-arrow.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -48,15 +51,17 @@ import { WRankWeek } from "@root/src/systems/wrank";
|
|||
|
||||
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 paddedName = TextAlign.padToMax(nameBlock, allNameBlocks);
|
||||
const cockroach = Layout.cockroach(char as any, row.historyKey);
|
||||
|
||||
const paddedBlock = TextAlign.padToMax(nameBlock, allNameBlocks);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue