fix: unify score submission on Score.submit, fix TGScore type drift, fix playedBy semantics for borrowed characters
- Score.submit/score/set.ts/score-inject.ts now all share one code path - TGScore consolidated to single canonical type (was duplicated in types.ts and score.ts) - Fixed atk/def/heal flat-vs-nested TGStats drift across leaderboard.ts, result layouts - Fixed playedBy semantics — now correctly identifies the actual player on borrowed characters - Attendance.allSubmitted now correctly matches against playedBy (borrower) not just userKey (owner) - score-inject gained atk/def/heal/date/played_by parameters for full parity with real submission - Added migrate-stats-shape.py and fix-class-keys.py maintenance scripts
This commit is contained in:
parent
da0f90f5d7
commit
049ea7b77f
23 changed files with 326 additions and 381 deletions
3
data/emojis/circle.json
Normal file
3
data/emojis/circle.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>"
|
||||||
|
}
|
||||||
52
data/updates/v0.9/update.json
Normal file
52
data/updates/v0.9/update.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"version": "v0.9",
|
||||||
|
"date": "2026-06-20",
|
||||||
|
"title": "Leaderboards, Results & Aligned Stats",
|
||||||
|
"layout": "default",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "new",
|
||||||
|
"label": "New",
|
||||||
|
"emoji": "✨",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Weekly Leaderboard in <:capella:> · <:procyon:> — live rankings updated after every score submission" },
|
||||||
|
{ "text": "TG Result posts — full breakdown after each TG, with W.Rank, score, K/D, and per-character stats" },
|
||||||
|
{ "text": "Weekly Highlights — most kills, most deaths, and next Storm/Luminous Bringer predictions" },
|
||||||
|
{ "text": "/tg call — officers and trusted players can end a TG early when it's called" },
|
||||||
|
{ "text": "/tg poll confirm-no — officially mark a TG as cancelled, with a clear visual indicator" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "improvement",
|
||||||
|
"label": "Improvements",
|
||||||
|
"emoji": "⚙️",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Leaderboard and Result layouts now have aligned columns — name, score, K/D and TG count line up cleanly across all players" },
|
||||||
|
{ "text": "Multiple display layouts available for both Leaderboard and Result — officers can switch between them" },
|
||||||
|
{ "text": "Secondary combat stats (ATK / DEF / Heal) now shown directly under each player's main stats when recorded" },
|
||||||
|
{ "text": "W.Rank shown on Result posts now reflects the rank at the moment the score was submitted, not the current rank — more accurate for historical TGs" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fix",
|
||||||
|
"label": "Fixes",
|
||||||
|
"emoji": "🔧",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Fixed a long-standing bug where K/D could silently show as 0/0 even when kills and deaths were recorded" },
|
||||||
|
{ "text": "Fixed weekly reset occasionally computing the wrong week after a Timezone inconsistency" },
|
||||||
|
{ "text": "Fixed Bringer assignment not correctly carrying over in some weeks" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "technical",
|
||||||
|
"label": "Under the hood",
|
||||||
|
"emoji": "🛠️",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Built a reusable text-alignment system for embeds, using real font metrics extracted directly from Discord's font" },
|
||||||
|
{ "text": "Persistent message system extended to support multiple independently-updatable embeds in one message" },
|
||||||
|
{ "text": "Added an event system so different parts of the bot can react to score submissions without being tightly coupled" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"examples": []
|
||||||
|
}
|
||||||
|
|
@ -273,5 +273,6 @@
|
||||||
"anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>",
|
"anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>",
|
||||||
"anima_atk": "<:anima_atk:1517702182710018179>",
|
"anima_atk": "<:anima_atk:1517702182710018179>",
|
||||||
"anima_def": "<:anima_def:1517700561468657785>",
|
"anima_def": "<:anima_def:1517700561468657785>",
|
||||||
"anima_massheal": "<:anima_massheal:1517702186434433146>"
|
"anima_massheal": "<:anima_massheal:1517702186434433146>",
|
||||||
|
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>"
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"wrank_down": (f) => `wrank_down_${f}`,
|
"wrank_down": (f) => `wrank_down_${f}`,
|
||||||
"wrank_x": (f) => `wrank_x_${f}`,
|
"wrank_x": (f) => `wrank_x_${f}`,
|
||||||
"anima-mastery_stats": (f) => `anima_${f}`,
|
"anima-mastery_stats": (f) => `anima_${f}`,
|
||||||
|
"circle": (f) => `circle_${f}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveEmojiName(dirName: string, filename: string): string {
|
function resolveEmojiName(dirName: string, filename: string): string {
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,13 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
|
||||||
.addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)"))
|
.addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)"))
|
||||||
.addIntegerOption((o) => o.setName("k").setDescription("Kills"))
|
.addIntegerOption((o) => o.setName("k").setDescription("Kills"))
|
||||||
.addIntegerOption((o) => o.setName("d").setDescription("Deaths"))
|
.addIntegerOption((o) => o.setName("d").setDescription("Deaths"))
|
||||||
|
.addIntegerOption((o) => o.setName("atk").setDescription("ATK damage"))
|
||||||
|
.addIntegerOption((o) => o.setName("def").setDescription("DEF damage taken"))
|
||||||
|
.addIntegerOption((o) => o.setName("heal").setDescription("Healing done"))
|
||||||
|
.addStringOption((o) => o
|
||||||
|
.setName("played_by")
|
||||||
|
.setDescription("Userkey of who actually played (if different from character owner)")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd.addSubcommand((s) => s
|
cmd.addSubcommand((s) => s
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,10 @@ async function autocompleteCharNames(
|
||||||
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
|
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const classKey = typeof c.class === "object" ? c.class?.key : c.class;
|
const classKey = typeof c.class === "object" ? c.class?.key : c.class;
|
||||||
return { name: `${classKey} ${c.level} ${c.name} [${c.nation}]`.trim(), value: c.name };
|
return {
|
||||||
|
name: `${classKey} ${c.level} ${c.name} [${c.nation}]`.trim(),
|
||||||
|
value: c.name
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.slice(0, 25);
|
.slice(0, 25);
|
||||||
return interaction.respond(results);
|
return interaction.respond(results);
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,15 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
||||||
|
|
||||||
const opts = Discord.Interaction.options(interaction);
|
const opts = Discord.Interaction.options(interaction);
|
||||||
const charName = opts.string({ key: "char_name", required: true })!;
|
const charName = opts.string({ key: "char_name", required: true })!;
|
||||||
|
const playedByArg = opts.string({ key: "played_by" }) ?? undefined;
|
||||||
const pts = opts.integer({ key: "pts", required: true })!;
|
const pts = opts.integer({ key: "pts", required: true })!;
|
||||||
const slot = opts.integer({ key: "slot", required: true })!;
|
const slot = opts.integer({ key: "slot", required: true })!;
|
||||||
const dateStr = opts.string({ key: "date" }) ?? new Date().toISOString().slice(0, 10);
|
const date = opts.string({ key: "date" }) ?? new Date().toISOString().slice(0, 10);
|
||||||
const k = opts.integer({ key: "k" }) ?? undefined;
|
const k = opts.integer({ key: "k" }) ?? undefined;
|
||||||
const d = opts.integer({ key: "d" }) ?? undefined;
|
const d = opts.integer({ key: "d" }) ?? undefined;
|
||||||
|
const atk = opts.integer({ key: "atk" }) ?? undefined;
|
||||||
|
const def = opts.integer({ key: "def" }) ?? undefined;
|
||||||
|
const heal = opts.integer({ key: "heal" }) ?? undefined;
|
||||||
|
|
||||||
const char = CharacterRegistry.find(charName);
|
const char = CharacterRegistry.find(charName);
|
||||||
if (!char) {
|
if (!char) {
|
||||||
|
|
@ -30,14 +34,19 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyKey = TGKey.from({ date: dateStr, slot });
|
const historyKey = TGKey.from({ date: date, slot });
|
||||||
|
|
||||||
Score.submit({
|
Score.submit({
|
||||||
character: char,
|
character: char,
|
||||||
pts,
|
pts,
|
||||||
k,
|
k,
|
||||||
d,
|
d,
|
||||||
|
atk,
|
||||||
|
def,
|
||||||
|
heal,
|
||||||
slot,
|
slot,
|
||||||
|
date,
|
||||||
|
playedBy: playedByArg,
|
||||||
submittedByOfficer: true,
|
submittedByOfficer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
import { Config } from "@systems/config";
|
import { Config } from "@systems/config";
|
||||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||||
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
|
import { Score } from "@systems/score";
|
||||||
|
import { detectSlot, normalizeSlot } from "@systems/scores";
|
||||||
import { getEffectiveCharacter } from "@systems/borrow";
|
import { getEffectiveCharacter } from "@systems/borrow";
|
||||||
import { replyAndDelete } from "@utils";
|
import { replyAndDelete } from "@utils";
|
||||||
import { Emoji } from "@systems/emojis";
|
import { Emoji } from "@systems/emojis";
|
||||||
import { Discord } from "@discord";
|
import { Discord } from "@discord";
|
||||||
import { User } from "@systems/users";
|
import { User } from "@systems/users";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
import { SlotHour } from "@root/src/types";
|
||||||
|
const log = Logger.for("score-set");
|
||||||
|
|
||||||
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
|
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
|
||||||
|
|
@ -47,20 +51,19 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
|
||||||
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
|
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
await submitScore({
|
log.debug(`Submitting score: slot=${slot} (type: ${typeof slot}) date=${new Date().toISOString().slice(0,10)}`);
|
||||||
userKey: borrowedFrom ?? userKey,
|
|
||||||
playedBy: borrowedFrom ? userKey : undefined,
|
await Score.submit({
|
||||||
characterName: char.name,
|
character: char,
|
||||||
cls: char.class.key,
|
playedBy: borrowedFrom ? userKey : undefined,
|
||||||
nation: char.nation,
|
pts: ptsArg!,
|
||||||
pts: ptsArg!,
|
|
||||||
k,
|
k,
|
||||||
d,
|
d,
|
||||||
slot,
|
|
||||||
atk,
|
atk,
|
||||||
def,
|
def,
|
||||||
heal,
|
heal,
|
||||||
submittedByOfficer: isOfficer && !!nameArg,
|
slot: slot as SlotHour,
|
||||||
|
submittedByOfficer: isOfficer && !!nameArg,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scoreEmoji = Emoji.get("score") || "📊";
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { Config } from "@systems/config";
|
||||||
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
|
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
|
||||||
import { getEffectiveCharacter } from "@systems/borrow";
|
import { getEffectiveCharacter } from "@systems/borrow";
|
||||||
import { format } from "@format";
|
import { format } from "@format";
|
||||||
import { getEmoji } from "@systems/emojis";
|
import { Emoji } from "@systems/emojis";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
|
||||||
|
const log = Logger.for("score-submit");
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -50,6 +53,8 @@ export namespace score {
|
||||||
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
|
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug(`Submitting score: slot=${slot} (type: ${typeof slot}) date=${new Date().toISOString().slice(0,10)}`);
|
||||||
|
|
||||||
await submitScore({
|
await submitScore({
|
||||||
userKey: borrowedFrom ?? userKey,
|
userKey: borrowedFrom ?? userKey,
|
||||||
playedBy: borrowedFrom ? userKey : undefined,
|
playedBy: borrowedFrom ? userKey : undefined,
|
||||||
|
|
@ -66,8 +71,8 @@ export namespace score {
|
||||||
submittedByOfficer,
|
submittedByOfficer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scoreEmoji = getEmoji("score") || "📊";
|
const scoreEmoji = Emoji.get("score") || "📊";
|
||||||
const kdEmoji = getEmoji("kd") || "⚔️";
|
const kdEmoji = Emoji.get("kd") || "⚔️";
|
||||||
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
|
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
|
||||||
const statsNote = [
|
const statsNote = [
|
||||||
atk !== undefined ? `ATK: ${atk}` : null,
|
atk !== undefined ? `ATK: ${atk}` : null,
|
||||||
|
|
|
||||||
|
|
@ -57,19 +57,21 @@ function save(): void {
|
||||||
/**
|
/**
|
||||||
* Check if all attendees have submitted scores.
|
* Check if all attendees have submitted scores.
|
||||||
*/
|
*/
|
||||||
allSubmitted(historyKey: TGKey): boolean {
|
allSubmitted(historyKey: TGKey): boolean {
|
||||||
const players = _data[historyKey] ?? [];
|
const players = _data[historyKey] ?? [];
|
||||||
if (players.length === 0) return false;
|
if (players.length === 0) return false;
|
||||||
try {
|
try {
|
||||||
const result = JSON.parse(
|
const result = JSON.parse(
|
||||||
fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8")
|
fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8")
|
||||||
);
|
);
|
||||||
const submitted = new Set(result.scores?.map((s: any) => s.userKey) ?? []);
|
const submitted = new Set(
|
||||||
return players.every((p) => submitted.has(p));
|
result.scores?.map((s: any) => s.playedBy ?? s.userKey) ?? []
|
||||||
} catch {
|
);
|
||||||
return false;
|
return players.every((p) => submitted.has(p));
|
||||||
}
|
} catch {
|
||||||
},
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all history keys (for listing past TGs).
|
* Get all history keys (for listing past TGs).
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { Nations } from "@systems/nations";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
import { Paths } from "@helpers/paths";
|
import { Paths } from "@helpers/paths";
|
||||||
import { TGKey } from "@systems/tg-key";
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
const log = Logger.for("upsert-score");
|
||||||
|
|
||||||
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history");
|
const HISTORY_DIR = path.join(__dirname, "../../data/tg-history");
|
||||||
|
|
||||||
|
|
@ -13,9 +15,12 @@ function historyPath(key: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadResult(date: string, slot: number): TGResult | null {
|
export function loadResult(date: string, slot: number): TGResult | null {
|
||||||
const path = historyPath(TGKey.from({ date, slot }));
|
const key = TGKey.from({ date, slot });
|
||||||
return Store.read(path);
|
const p = historyPath(key);
|
||||||
|
log.debug(`loadResult: date=${date} slot=${slot} key=${key} path=${p}`);
|
||||||
|
return Store.read(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveResult(result: TGResult): void {
|
export function saveResult(result: TGResult): void {
|
||||||
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
||||||
const path = historyPath(TGKey.from({ date: result.date, slot: result.slot }));
|
const path = historyPath(TGKey.from({ date: result.date, slot: result.slot }));
|
||||||
|
|
@ -23,23 +28,27 @@ export function saveResult(result: TGResult): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertScore(score: TGScore): void {
|
export function upsertScore(score: TGScore): void {
|
||||||
const result = loadResult(score.date, score.slot) ?? {
|
let result = loadResult(score.date, score.slot);
|
||||||
slot: score.slot,
|
if (!result || !result.date || !result.slot) {
|
||||||
date: score.date,
|
result = {
|
||||||
confirmed: false,
|
slot: score.slot,
|
||||||
nationKD: {
|
date: score.date,
|
||||||
source: Nation.Procyon,
|
confirmed: false,
|
||||||
capella: { k: 0, d: 0 },
|
nationKD: {
|
||||||
procyon: { k: 0, d: 0 },
|
source: Nation.Procyon,
|
||||||
},
|
capella: { k: 0, d: 0 },
|
||||||
scores: [],
|
procyon: { k: 0, d: 0 },
|
||||||
};
|
},
|
||||||
|
scores: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Overwrite existing score for this player+slot
|
// Overwrite existing score for this player+slot
|
||||||
result.scores = result.scores.filter(
|
result.scores = result.scores.filter(
|
||||||
(s) => !(s.userKey === score.userKey && s.characterName === score.characterName && s.slot === score.slot && s.date === score.date)
|
(s) => !(s.userKey === score.userKey && s.characterName === score.characterName && s.slot === score.slot && s.date === score.date)
|
||||||
);
|
);
|
||||||
result.scores.push(score);
|
result.scores.push(score);
|
||||||
|
log.debug(`upsertScore: about to save — result.date=${result.date} result.slot=${result.slot}`);
|
||||||
saveResult(result);
|
saveResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,9 @@
|
||||||
if (!score) continue;
|
if (!score) continue;
|
||||||
totalKills += score.k ?? 0;
|
totalKills += score.k ?? 0;
|
||||||
totalDeaths += score.d ?? 0;
|
totalDeaths += score.d ?? 0;
|
||||||
totalAtk += score.atk ?? 0;
|
totalAtk += score.stats?.atk ?? 0;
|
||||||
totalDef += score.def ?? 0;
|
totalDef += score.stats?.def ?? 0;
|
||||||
totalHeal += score.heal ?? 0;
|
totalHeal += score.stats?.heal ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder } from "discord.js";
|
||||||
import { Nation, Character, ClassKey, CLASSES, UserKey } from "@types";
|
import { Nation, Character, ClassKey, CLASSES, UserKey, TGScore } from "@types";
|
||||||
import { TGKey } from "@systems/tg-key";
|
import { TGKey } from "@systems/tg-key";
|
||||||
import { Score, TGScore } from "@systems/score";
|
import { Score } from "@systems/score";
|
||||||
import { Attendance } from "@systems/attendance";
|
import { Attendance } from "@systems/attendance";
|
||||||
import { WRank, WRankEntry } from "@systems/wrank";
|
import { WRank, WRankEntry } from "@systems/wrank";
|
||||||
import { Bringer } from "@systems/bringer";
|
import { Bringer } from "@systems/bringer";
|
||||||
|
|
@ -24,11 +24,6 @@
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface NationContext {
|
|
||||||
nationHasRank: boolean;
|
|
||||||
nationHasDelta: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResultRow {
|
interface ResultRow {
|
||||||
character: Character;
|
character: Character;
|
||||||
score?: TGScore;
|
score?: TGScore;
|
||||||
|
|
@ -36,196 +31,60 @@
|
||||||
leavesCount: number;
|
leavesCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildNationContext(rows: ResultRow[]): NationContext {
|
|
||||||
return {
|
|
||||||
nationHasRank: rows.some((r) => r.position && r.position.currentRank !== 0),
|
|
||||||
nationHasDelta: rows.some((r) => r.position?.previousRank !== undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Row formatting ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatResultRow(row: ResultRow, context: NationContext, goal: number): string {
|
|
||||||
const char = row.character;
|
|
||||||
|
|
||||||
// W.Rank
|
|
||||||
const wrEntry: WRankEntry | null = row.position ? {
|
|
||||||
character: char,
|
|
||||||
weeklyPoints: row.score?.pts ?? 0,
|
|
||||||
tgCount: 1,
|
|
||||||
currentRank: row.position.currentRank,
|
|
||||||
previousRank: row.position.previousRank,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const wrank = format.wrank.row(wrEntry, goal, context);
|
|
||||||
|
|
||||||
// Character
|
|
||||||
const classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
|
||||||
const classStr = classKey ? (Emoji.class(classKey) || classKey) : "?";
|
|
||||||
const charStr = `${classStr} ${char.level} ${char.name}`;
|
|
||||||
|
|
||||||
// Bringer
|
|
||||||
const bringer = Bringer.get({ nation: char.nation }) === char.name
|
|
||||||
? ` · ${format.bringer(char.nation)}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// Cockroach
|
|
||||||
const cockroach = row.leavesCount > 0
|
|
||||||
? ` ${Leaves.formatIndicator({ characterName: char.name })}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (!row.score) {
|
|
||||||
return `${wrank} ${charStr}${bringer}${cockroach} — —`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scoreEmoji = Emoji.get("score") || "📊";
|
|
||||||
const pts = `${scoreEmoji} ${format.scoreBold(row.score.pts)}`;
|
|
||||||
const kd = (row.score.k || row.score.d)
|
|
||||||
? ` · ${format.kd(row.score.k ?? 0, row.score.d ?? 0)}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// Stats on new line with indent
|
|
||||||
const statsStr = format.stats(
|
|
||||||
row.score.atk || row.score.def || row.score.heal
|
|
||||||
? { atk: row.score.atk, def: row.score.def, heal: row.score.heal }
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
const mainLine = `${wrank} ${charStr}${bringer}${cockroach} — ${pts}${kd}`;
|
|
||||||
const statsLine = statsStr ? `\u3000${statsStr}` : "";
|
|
||||||
|
|
||||||
return statsLine ? `${mainLine}\n${statsLine}` : mainLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Nation field ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildNationField(rows: ResultRow[], goal: number): string {
|
|
||||||
if (rows.length === 0) return "—";
|
|
||||||
const context = buildNationContext(rows);
|
|
||||||
|
|
||||||
// Sort by score descending (per nation since rows are already filtered)
|
|
||||||
const sorted = [...rows].sort((a, b) => (b.score?.pts ?? 0) - (a.score?.pts ?? 0));
|
|
||||||
|
|
||||||
return sorted.map((r) => formatResultRow(r, context, goal)).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Embed ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildResultEmbed(historyKey: TGKey, rows: ResultRow[]): EmbedBuilder {
|
|
||||||
const goal = Config.get({ section: "wrank", key: "goal" });
|
|
||||||
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 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 embed = new EmbedBuilder()
|
|
||||||
.setTitle(`⚔️ TG Result — ${format.date(new Date(date), "dd/MM/YYYY")} · ${slot}:00`)
|
|
||||||
.setColor(0xe8a317)
|
|
||||||
.addFields(
|
|
||||||
{
|
|
||||||
name: `${capellaEmoji} Capella${(capK || capD) ? ` — ${format.kd(capK, capD)}` : ""}`,
|
|
||||||
value: buildNationField(capellaRows, goal) || "—",
|
|
||||||
inline: false,
|
|
||||||
},
|
|
||||||
{ name: "\u200b", value: "\u200b", inline: false },
|
|
||||||
{
|
|
||||||
name: `${procyonEmoji} Procyon${(proK || proD) ? ` — ${format.kd(proK, proD)}` : ""}`,
|
|
||||||
value: buildNationField(procyonRows, goal) || "—",
|
|
||||||
inline: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setFooter({ text: `TG Result · ${TGKey.toDisplay(historyKey)}` })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRows(historyKey: TGKey): ResultRow[] {
|
function buildRows(historyKey: TGKey): ResultRow[] {
|
||||||
const { slot, date } = TGKey.parse(historyKey);
|
const { slot, date } = TGKey.parse(historyKey);
|
||||||
let players: UserKey[] = Attendance.players(historyKey);
|
let players: UserKey[] = Attendance.players(historyKey);
|
||||||
|
|
||||||
if (players.length === 0) {
|
if (players.length === 0) {
|
||||||
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
|
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
|
||||||
if (history?.scores) {
|
if (history?.scores) {
|
||||||
players = [...new Set(history.scores.map((s: TGScore) => s.userKey))];
|
players = [...new Set(history.scores.map((s: TGScore) => s.playedBy ?? s.userKey))];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekKey = WRank.weekKey(new Date(date));
|
const weekKey = WRank.weekKey(new Date(date));
|
||||||
const addedUsers = new Set<UserKey>();
|
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
|
||||||
const rows: ResultRow[] = [];
|
const rows: ResultRow[] = [];
|
||||||
|
|
||||||
for (const userKey of players) {
|
// Match each ATTENDEE (player) directly against the scores where
|
||||||
const chars = CharacterRegistry.forUser(userKey);
|
// (playedBy ?? userKey) === that player — this is the authoritative
|
||||||
|
// link, not "does this player own/share a character with a score".
|
||||||
for (const char of chars) {
|
for (const playerKey of players) {
|
||||||
const score = Score.get({ character: char, slot, historyKey });
|
const score = history?.scores.find((s: TGScore) => (s.playedBy ?? s.userKey) === playerKey);
|
||||||
if (!score) continue;
|
if (!score) continue; // attendee hasn't submitted yet — handled elsewhere (placeholder row)
|
||||||
|
|
||||||
const wrEntry = WRank.entry(char.name, char.nation, weekKey);
|
const foundChar = CharacterRegistry.find(score.characterName);
|
||||||
const position = score.wRankAtSubmission
|
const classKey = (typeof score.class === "object" ? (score.class as any)?.key : score.class) as ClassKey;
|
||||||
? {
|
const char: Character = foundChar ?? {
|
||||||
currentRank: score.wRankAtSubmission.rank,
|
name: score.characterName,
|
||||||
previousRank: score.wRankAtSubmission.rank - score.wRankAtSubmission.delta,
|
class: CLASSES[classKey] ?? { key: classKey, name: classKey, shortName: classKey },
|
||||||
}
|
level: 0,
|
||||||
: wrEntry
|
nation: score.nation,
|
||||||
? { currentRank: wrEntry.currentRank, previousRank: wrEntry.previousRank }
|
ownerKey: score.userKey,
|
||||||
: undefined;
|
};
|
||||||
|
|
||||||
rows.push({
|
const wrEntry = WRank.entry(char.name, char.nation, weekKey);
|
||||||
character: char,
|
const position = score.wRankAtSubmission
|
||||||
score,
|
? {
|
||||||
position,
|
currentRank: score.wRankAtSubmission.rank,
|
||||||
leavesCount: Leaves.countForChar({ characterName: char.name }),
|
previousRank: score.wRankAtSubmission.rank - score.wRankAtSubmission.delta,
|
||||||
});
|
}
|
||||||
addedUsers.add(userKey);
|
: wrEntry
|
||||||
break;
|
? { currentRank: wrEntry.currentRank, previousRank: wrEntry.previousRank }
|
||||||
}
|
: undefined;
|
||||||
|
|
||||||
if (!addedUsers.has(userKey)) {
|
rows.push({
|
||||||
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
|
character: char,
|
||||||
const score = history?.scores.find((s: TGScore) => s.userKey === userKey);
|
score,
|
||||||
if (score) {
|
position,
|
||||||
const foundChar = CharacterRegistry.find(score.characterName);
|
leavesCount: Leaves.countForChar({ characterName: char.name }),
|
||||||
const classKey = (typeof score.class === "object"
|
});
|
||||||
? (score.class as any)?.key
|
}
|
||||||
: score.class) as ClassKey;
|
|
||||||
const char: Character = foundChar ?? {
|
return rows;
|
||||||
name: score.characterName,
|
}
|
||||||
class: CLASSES[classKey] ?? { key: classKey, name: classKey, shortName: classKey },
|
|
||||||
level: 0,
|
|
||||||
nation: score.nation,
|
|
||||||
ownerKey: score.userKey,
|
|
||||||
};
|
|
||||||
const wrEntry = WRank.entry(char.name, char.nation, weekKey);
|
|
||||||
const position = score.wRankAtSubmission
|
|
||||||
? {
|
|
||||||
currentRank: score.wRankAtSubmission.rank,
|
|
||||||
previousRank: score.wRankAtSubmission.rank - score.wRankAtSubmission.delta,
|
|
||||||
}
|
|
||||||
: wrEntry
|
|
||||||
? { currentRank: wrEntry.currentRank, previousRank: wrEntry.previousRank }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
rows.push({ character: char, score, position, leavesCount: 0 });
|
|
||||||
addedUsers.add(userKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,34 +9,32 @@
|
||||||
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
|
* Score.submit({ character, borrowedFrom, pts, k, d, slot })
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Character, Nation, UserKey, SlotHour } from "@types";
|
import { Character, Nation, UserKey, SlotHour, TGStats, TGScore } from "@types";
|
||||||
import { WRank } from "@systems/wrank";
|
import { WRank } from "@systems/wrank";
|
||||||
import { Store } from "@systems/store";
|
import { Store } from "@systems/store";
|
||||||
import { Paths } from "@helpers/paths";
|
import { Paths } from "@helpers/paths";
|
||||||
import { TGKey } from "@systems/tg-key";
|
import { TGKey } from "@systems/tg-key";
|
||||||
import { RuntimeEvents } from "@systems/runtime";
|
import { RuntimeEvents } from "@systems/runtime";
|
||||||
|
|
||||||
export interface TGScore {
|
// export interface TGScore {
|
||||||
userKey: UserKey;
|
// userKey: UserKey;
|
||||||
playedBy?: UserKey; // if borrowed
|
// playedBy?: UserKey; // if borrowed
|
||||||
characterName: string;
|
// characterName: string;
|
||||||
class: string;
|
// class: string;
|
||||||
nation: Nation;
|
// nation: Nation;
|
||||||
pts: number;
|
// pts: number;
|
||||||
k?: number;
|
// k?: number;
|
||||||
d?: number;
|
// d?: number;
|
||||||
atk?: number;
|
// stats?: TGStats;
|
||||||
def?: number;
|
// submittedAt: string;
|
||||||
heal?: number;
|
// slot: SlotHour;
|
||||||
submittedAt: string;
|
// date: string;
|
||||||
slot: SlotHour;
|
// submittedByOfficer: boolean;
|
||||||
date: string;
|
// wRankAtSubmission?: {
|
||||||
submittedByOfficer: boolean;
|
// rank: number;
|
||||||
wRankAtSubmission?: {
|
// delta: number;
|
||||||
rank: number;
|
// };
|
||||||
delta: number;
|
// }
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeeklySummary {
|
export interface WeeklySummary {
|
||||||
userKey: UserKey;
|
userKey: UserKey;
|
||||||
|
|
@ -114,67 +112,65 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
|
||||||
* Submit a score for a character.
|
* Submit a score for a character.
|
||||||
* Handles W.Rank snapshot at submission time.
|
* Handles W.Rank snapshot at submission time.
|
||||||
*/
|
*/
|
||||||
async submit({ character, borrowedFrom, pts, k, d, atk, def, heal, slot, submittedByOfficer }: {
|
async submit({ character, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByOfficer }: {
|
||||||
character: Character;
|
character: Character;
|
||||||
borrowedFrom?: UserKey;
|
playedBy?: UserKey;
|
||||||
pts: number;
|
pts: number;
|
||||||
k?: number;
|
k?: number;
|
||||||
d?: number;
|
d?: number;
|
||||||
atk?: number;
|
atk?: number;
|
||||||
def?: number;
|
def?: number;
|
||||||
heal?: number;
|
heal?: number;
|
||||||
slot: SlotHour;
|
slot: SlotHour;
|
||||||
submittedByOfficer?: boolean;
|
date?: string; // ← NEW, optional, defaults to today
|
||||||
}): Promise<void> {
|
submittedByOfficer?: boolean;
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
}): Promise<void> {
|
||||||
const historyKey = TGKey.current({ slot });
|
const resolvedDate = date ?? new Date().toISOString().slice(0, 10);
|
||||||
const history = loadHistory(historyKey);
|
const historyKey = TGKey.from({ date: resolvedDate, slot });
|
||||||
|
const history = loadHistory(historyKey);
|
||||||
// Snapshot W.Rank before recording score
|
|
||||||
const existingEntry = WRank.entry(character.name, character.nation);
|
const existingEntry = WRank.entry(character.name, character.nation);
|
||||||
const wRankAtSubmission = existingEntry ? {
|
const wRankAtSubmission = existingEntry ? {
|
||||||
rank: existingEntry.currentRank,
|
rank: existingEntry.currentRank,
|
||||||
delta: existingEntry.currentRank - (existingEntry.previousRank ?? existingEntry.currentRank),
|
delta: existingEntry.currentRank - (existingEntry.previousRank ?? existingEntry.currentRank),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
const score: TGScore = {
|
const score: TGScore = {
|
||||||
userKey: character.ownerKey,
|
userKey: character.ownerKey,
|
||||||
playedBy: borrowedFrom,
|
playedBy: playedBy,
|
||||||
characterName: character.name,
|
characterName: character.name,
|
||||||
class: character.class.key,
|
class: character.class.key,
|
||||||
nation: character.nation,
|
nation: character.nation,
|
||||||
pts,
|
pts,
|
||||||
k,
|
k,
|
||||||
d,
|
d,
|
||||||
atk,
|
stats: atk !== undefined || def !== undefined || heal !== undefined
|
||||||
def,
|
? { atk, def, heal }
|
||||||
heal,
|
: undefined,
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
slot,
|
slot,
|
||||||
date,
|
date: resolvedDate,
|
||||||
submittedByOfficer: submittedByOfficer ?? false,
|
submittedByOfficer: submittedByOfficer ?? false,
|
||||||
wRankAtSubmission,
|
wRankAtSubmission,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upsert — replace existing score for same character/slot
|
history.scores = history.scores.filter(
|
||||||
history.scores = history.scores.filter(
|
(s) => !(s.userKey === character.ownerKey &&
|
||||||
(s) => !(s.userKey === character.ownerKey &&
|
s.characterName === character.name &&
|
||||||
s.characterName === character.name &&
|
s.slot === slot &&
|
||||||
s.slot === slot &&
|
s.date === resolvedDate)
|
||||||
s.date === date)
|
);
|
||||||
);
|
history.scores.push(score);
|
||||||
history.scores.push(score);
|
saveHistory(historyKey, history);
|
||||||
saveHistory(historyKey, history);
|
|
||||||
|
WRank.recordScore(
|
||||||
// Record in W.Rank
|
character.ownerKey,
|
||||||
WRank.recordScore(
|
character.name,
|
||||||
character.ownerKey,
|
character.class.key,
|
||||||
character.name,
|
character.nation,
|
||||||
character.class.key,
|
pts,
|
||||||
character.nation,
|
historyKey
|
||||||
pts,
|
);
|
||||||
historyKey
|
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character });
|
||||||
);
|
},
|
||||||
await RuntimeEvents.emit("scoreSubmitted", { historyKey, character });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
@ -3,6 +3,8 @@ import { Config } from "./config";
|
||||||
import { upsertScore, todayString } from "./history";
|
import { upsertScore, todayString } from "./history";
|
||||||
import { WRank } from "./wrank";
|
import { WRank } from "./wrank";
|
||||||
import { TGKey } from "@systems/tg-key";
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
import { Logger } from "@systems/logger";
|
||||||
|
const log = Logger.for("system-scores");
|
||||||
|
|
||||||
// Normalize a slot string to a 24h integer hour
|
// Normalize a slot string to a 24h integer hour
|
||||||
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
|
// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon"
|
||||||
|
|
@ -72,7 +74,9 @@ export interface ScoreSubmission {
|
||||||
|
|
||||||
export function submitScore(sub: ScoreSubmission): void {
|
export function submitScore(sub: ScoreSubmission): void {
|
||||||
const date = sub.date ?? todayString();
|
const date = sub.date ?? todayString();
|
||||||
|
log.debug(`sub.slot=${sub.slot} date=${date}`);
|
||||||
const historyKey = TGKey.from({ date, slot: sub.slot });
|
const historyKey = TGKey.from({ date, slot: sub.slot });
|
||||||
|
log.debug(`historyKey=${historyKey}`);
|
||||||
|
|
||||||
const score: TGScore = {
|
const score: TGScore = {
|
||||||
userKey: sub.userKey,
|
userKey: sub.userKey,
|
||||||
|
|
@ -92,6 +96,7 @@ export function submitScore(sub: ScoreSubmission): void {
|
||||||
submittedByOfficer: sub.submittedByOfficer,
|
submittedByOfficer: sub.submittedByOfficer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log.debug(`score.date=${score.date} score.slot=${score.slot}`);
|
||||||
upsertScore(score);
|
upsertScore(score);
|
||||||
WRank.recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
WRank.recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@
|
||||||
* TG.getWeeklySummary({ character })
|
* TG.getWeeklySummary({ character })
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Nation, Character, UserKey, SlotHour } from "@types";
|
import { Nation, Character, UserKey, SlotHour, TGScore } from "@types";
|
||||||
import { WRank } from "@systems/wrank";
|
import { WRank } from "@systems/wrank";
|
||||||
import { Bringer } from "@systems/bringer";
|
import { Bringer } from "@systems/bringer";
|
||||||
import { Score, TGScore, WeeklySummary } from "@systems/score";
|
import { Score, WeeklySummary } from "@systems/score";
|
||||||
import { Attendance } from "@systems/attendance";
|
import { Attendance } from "@systems/attendance";
|
||||||
import { Config } from "@systems/config";
|
import { Config } from "@systems/config";
|
||||||
import { TGKey } from "@systems/tg-key";
|
import { TGKey } from "@systems/tg-key";
|
||||||
|
|
|
||||||
28
src/types.ts
28
src/types.ts
|
|
@ -193,19 +193,23 @@ export interface PollState {
|
||||||
// ─── Scores ──────────────────────────────────────────────────────────────────
|
// ─── Scores ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TGScore {
|
export interface TGScore {
|
||||||
userKey: string;
|
userKey: UserKey;
|
||||||
characterName: string;
|
playedBy?: UserKey; // if borrowed
|
||||||
class: ClassKey;
|
characterName: string;
|
||||||
nation: Nation; // snapshotted at submission time
|
class: string;
|
||||||
pts: number;
|
nation: Nation;
|
||||||
k?: number;
|
pts: number;
|
||||||
d?: number;
|
k?: number;
|
||||||
stats?: TGStats;
|
d?: number;
|
||||||
submittedAt: string; // ISO timestamp
|
stats?: TGStats;
|
||||||
slot: number; // TG hour
|
submittedAt: string;
|
||||||
date: string; // YYYY-MM-DD
|
slot: SlotHour;
|
||||||
|
date: string;
|
||||||
submittedByOfficer: boolean;
|
submittedByOfficer: boolean;
|
||||||
playedBy?: string; // userKey of who actually played (if borrowed)
|
wRankAtSubmission?: {
|
||||||
|
rank: number;
|
||||||
|
delta: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TGStats {
|
export interface TGStats {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import { Runtime } from "@systems/runtime";
|
import { Runtime } from "@systems/runtime";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { TGStats } from "@root/src/types";
|
||||||
|
|
||||||
const log = Logger.for("leaderboard-ui");
|
const log = Logger.for("leaderboard-ui");
|
||||||
|
|
||||||
|
|
@ -28,7 +29,7 @@
|
||||||
tgCount: number;
|
tgCount: number;
|
||||||
totalKills: number;
|
totalKills: number;
|
||||||
totalDeaths: number;
|
totalDeaths: number;
|
||||||
stats?: { atk?: number; def?: number; heal?: number };
|
stats?: TGStats;
|
||||||
position?: { currentRank: number; previousRank?: number };
|
position?: { currentRank: number; previousRank?: number };
|
||||||
leavesCount: number;
|
leavesCount: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText));
|
const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText));
|
||||||
const atkText = format.statText("anima_atk", row.stats.atk, "⚔️");
|
const atkText = format.statText("anima_atk", row.stats.atk, "⚔️");
|
||||||
const defText = format.statText("anima_def", row.stats.def, "🛡️");
|
const defText = format.statText("anima_def", row.stats.def, "🛡️");
|
||||||
const healText = format.statText("anima_massheal", row.stats.heal, "💚");
|
const healText = format.statText("circle_massheal_purple", row.stats.heal, "💚");
|
||||||
|
|
||||||
const statsLine = `${prefixGap} ${TextAlign.gap(4)}${TextAlign.padToMax(atkText, scoreColumn)} ${TextAlign.gap(KD_GAP)}${TextAlign.padToMax(defText, kdColumn)} ${TextAlign.gap(HEAL_GAP)}${TextAlign.padLeftToMax(healText, tgsColumn)}`;
|
const statsLine = `${prefixGap} ${TextAlign.gap(4)}${TextAlign.padToMax(atkText, scoreColumn)} ${TextAlign.gap(KD_GAP)}${TextAlign.padToMax(defText, kdColumn)} ${TextAlign.gap(HEAL_GAP)}${TextAlign.padLeftToMax(healText, tgsColumn)}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { Config } from "@systems/config";
|
import { Config } from "@systems/config";
|
||||||
import { Logger } from "@systems/logger";
|
import { Logger } from "@systems/logger";
|
||||||
import { Runtime } from "@systems/runtime";
|
import { Runtime } from "@systems/runtime";
|
||||||
|
import { TGStats } from "@root/src/types";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
|
|
@ -29,9 +30,7 @@
|
||||||
pts: number;
|
pts: number;
|
||||||
k?: number;
|
k?: number;
|
||||||
d?: number;
|
d?: number;
|
||||||
atk?: number;
|
stats?: TGStats;
|
||||||
def?: number;
|
|
||||||
heal?: number;
|
|
||||||
wRankAtSubmission?: { rank: number; delta: number };
|
wRankAtSubmission?: { rank: number; delta: number };
|
||||||
};
|
};
|
||||||
position?: { currentRank: number; previousRank?: number };
|
position?: { currentRank: number; previousRank?: number };
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,7 @@
|
||||||
|
|
||||||
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
|
||||||
const statsStr = row.score
|
const statsStr = row.score ? format.stats(row.score.stats) : "";
|
||||||
? format.stats(
|
|
||||||
row.score.atk || row.score.def || row.score.heal
|
|
||||||
? { atk: row.score.atk, def: row.score.def, heal: row.score.heal }
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return statsStr ? `${mainLine}\n┕ ${statsStr}` : mainLine;
|
return statsStr ? `${mainLine}\n┕ ${statsStr}` : mainLine;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,7 @@
|
||||||
const wrEntry = Layout.wrankEntry(char as any, row.position, row.score?.pts, 1);
|
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 classKey = (typeof char.class === "object" ? char.class?.key : char.class) as ClassKey;
|
||||||
const statsStr = row.score
|
const statsStr = row.score ? format.stats(row.score.stats) : "";
|
||||||
? format.stats(
|
|
||||||
row.score.atk || row.score.def || row.score.heal
|
|
||||||
? { atk: row.score.atk, def: row.score.def, heal: row.score.heal }
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const tokens: Record<string, string> = {
|
const tokens: Record<string, string> = {
|
||||||
wrank: Layout.wrank(wrEntry, goal, context),
|
wrank: Layout.wrank(wrEntry, goal, context),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Sequential result layout — same alignment technique as the
|
* Sequential result layout — name, score, and K/D padded via TextAlign,
|
||||||
* leaderboard's "sequential" layout: name, score, and K/D padded via
|
* computed per-nation. Secondary stats (atk/def/heal) read from the
|
||||||
* TextAlign, computed per-nation. Secondary stats (atk/def/heal) shown
|
* nested TGStats shape (row.score.stats), shown on an indented second
|
||||||
* on an indented second line when present.
|
* line when present, column-locked under score/kd.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
|
@ -18,14 +18,11 @@
|
||||||
|
|
||||||
const TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}";
|
const TEMPLATE = "{wrank} {class} {name}{indicators} {score} {kd}";
|
||||||
|
|
||||||
// Same gap values tuned for the leaderboard's sequential-extra-stats —
|
|
||||||
// keeps score/atk and kd/def column-locked together.
|
|
||||||
const SCORE_GAP = 4;
|
|
||||||
const KD_GAP = 4;
|
const KD_GAP = 4;
|
||||||
|
const SCORE_GAP = 4;
|
||||||
const DEF_GAP = 1;
|
const DEF_GAP = 1;
|
||||||
const HEAL_GAP = 4;
|
const HEAL_GAP = 4;
|
||||||
const DEF_WIDTH_OFFSET = -0.1; // negative width units, pulls def slightly left
|
|
||||||
|
|
||||||
function formatRow(
|
function formatRow(
|
||||||
row: ResultRow,
|
row: ResultRow,
|
||||||
context: NationContext,
|
context: NationContext,
|
||||||
|
|
@ -44,8 +41,6 @@
|
||||||
const scoreText = row.score ? format.scoreBold(row.score.pts) : "—";
|
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 kdText = row.score && (row.score.k || row.score.d) ? format.kd(row.score.k ?? 0, row.score.d ?? 0) : "—";
|
||||||
|
|
||||||
// Column targets combine primary + secondary stat widths, so the main
|
|
||||||
// row makes room for the stats line beneath it.
|
|
||||||
const scoreColumn = [...allScores, ...allAtks];
|
const scoreColumn = [...allScores, ...allAtks];
|
||||||
const kdColumn = [...allKds, ...allDefs];
|
const kdColumn = [...allKds, ...allDefs];
|
||||||
|
|
||||||
|
|
@ -60,16 +55,19 @@
|
||||||
|
|
||||||
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
const mainLine = Layout.formatRow(TEMPLATE, tokens);
|
||||||
|
|
||||||
const hasStats = row.score && (row.score.atk || row.score.def || row.score.heal);
|
// Read stats from the NESTED shape (row.score.stats), matching TGScore's
|
||||||
|
// actual canonical structure.
|
||||||
|
const stats = row.score?.stats;
|
||||||
|
const hasStats = !!stats && !!(stats.atk || stats.def || stats.heal);
|
||||||
if (!hasStats) return mainLine;
|
if (!hasStats) return mainLine;
|
||||||
|
|
||||||
const prefixText = `${tokens.wrank} ${tokens.class} ${tokens.name}${tokens.indicators}`;
|
const prefixText = `${tokens.wrank} ${tokens.class} ${tokens.name}${tokens.indicators}`;
|
||||||
const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText));
|
const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText));
|
||||||
const atkText = format.statText("anima_atk", row.score!.atk, "⚔️");
|
const atkText = format.statText("anima_atk", stats!.atk, "⚔️");
|
||||||
const defText = format.statText("anima_def", row.score!.def, "🛡️");
|
const defText = format.statText("anima_def", stats!.def, "🛡️");
|
||||||
const healText = format.statText("anima_massheal", row.score!.heal, "💚");
|
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.padToMaxOffset(defText, kdColumn, DEF_WIDTH_OFFSET)} ${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}`;
|
return `${mainLine}\n${statsLine}`;
|
||||||
}
|
}
|
||||||
|
|
@ -95,12 +93,12 @@
|
||||||
const capellaKds = sortedCapella.map((r) => r.score && (r.score.k || r.score.d) ? format.kd(r.score.k ?? 0, r.score.d ?? 0) : "—");
|
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 procyonKds = sortedProcyon.map((r) => r.score && (r.score.k || r.score.d) ? format.kd(r.score.k ?? 0, r.score.d ?? 0) : "—");
|
||||||
|
|
||||||
const atkEmoji = Emoji.get("atk") || "⚔️";
|
const atkEmoji = Emoji.get("anima_atk") || "⚔️";
|
||||||
const defEmoji = Emoji.get("def") || "🛡️";
|
const defEmoji = Emoji.get("anima_def") || "🛡️";
|
||||||
const capellaAtks = sortedCapella.map((r) => r.score?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.atk)}` : "");
|
const capellaAtks = sortedCapella.map((r) => r.score?.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.stats.atk)}` : "");
|
||||||
const procyonAtks = sortedProcyon.map((r) => r.score?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.atk)}` : "");
|
const procyonAtks = sortedProcyon.map((r) => r.score?.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.stats.atk)}` : "");
|
||||||
const capellaDefs = sortedCapella.map((r) => r.score?.def ? `${defEmoji} ${format.number.abbrev(r.score.def)}` : "");
|
const capellaDefs = sortedCapella.map((r) => r.score?.stats?.def ? `${defEmoji} ${format.number.abbrev(r.score.stats.def)}` : "");
|
||||||
const procyonDefs = sortedProcyon.map((r) => r.score?.def ? `${defEmoji} ${format.number.abbrev(r.score.def)}` : "");
|
const procyonDefs = sortedProcyon.map((r) => r.score?.stats?.def ? `${defEmoji} ${format.number.abbrev(r.score.stats.def)}` : "");
|
||||||
|
|
||||||
const capK = capellaRows.reduce((s, r) => s + (r.score?.k ?? 0), 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 capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0);
|
||||||
|
|
@ -128,7 +126,7 @@
|
||||||
|
|
||||||
export const sequentialResultLayout: ResultLayout = {
|
export const sequentialResultLayout: ResultLayout = {
|
||||||
name: "sequential",
|
name: "sequential",
|
||||||
description: "Name/score/K-D aligned via TextAlign, stats on indented second line",
|
description: "Name/score/K-D aligned via TextAlign, stats (nested TGStats) on indented second line",
|
||||||
buildEmbed,
|
buildEmbed,
|
||||||
formatRow: formatRow as any,
|
formatRow: formatRow as any,
|
||||||
};
|
};
|
||||||
Loading…
Add table
Reference in a new issue