diff --git a/data/emojis/circle.json b/data/emojis/circle.json new file mode 100644 index 0000000..0ad977f --- /dev/null +++ b/data/emojis/circle.json @@ -0,0 +1,3 @@ +{ + "circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>" +} \ No newline at end of file diff --git a/data/updates/v0.9/update.json b/data/updates/v0.9/update.json new file mode 100644 index 0000000..a65bb31 --- /dev/null +++ b/data/updates/v0.9/update.json @@ -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": [] +} \ No newline at end of file diff --git a/messages/emojis.json b/messages/emojis.json index 9da9679..6af1d2c 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -273,5 +273,6 @@ "anima_anima_massheal": "<:anima_anima_massheal:1517697813402882160>", "anima_atk": "<:anima_atk:1517702182710018179>", "anima_def": "<:anima_def:1517700561468657785>", - "anima_massheal": "<:anima_massheal:1517702186434433146>" + "anima_massheal": "<:anima_massheal:1517702186434433146>", + "circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>" } \ No newline at end of file diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts index 452d83e..7db788b 100644 --- a/scripts/upload-emojis.ts +++ b/scripts/upload-emojis.ts @@ -58,6 +58,7 @@ "wrank_down": (f) => `wrank_down_${f}`, "wrank_x": (f) => `wrank_x_${f}`, "anima-mastery_stats": (f) => `anima_${f}`, + "circle": (f) => `circle_${f}`, }; function resolveEmojiName(dirName: string, filename: string): string { diff --git a/src/commands/tgAdmin.ts b/src/commands/tgAdmin.ts index 843ed1e..41a4e0a 100644 --- a/src/commands/tgAdmin.ts +++ b/src/commands/tgAdmin.ts @@ -140,6 +140,13 @@ export function buildTgAdminCommand(): SlashCommandBuilder { .addStringOption((o) => o.setName("date").setDescription("Date YYYY-MM-DD (defaults today)")) .addIntegerOption((o) => o.setName("k").setDescription("Kills")) .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 diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index cf105b4..862c047 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -46,7 +46,10 @@ async function autocompleteCharNames( .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) .map((c) => { 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); return interaction.respond(results); diff --git a/src/subcommands/admin/score-inject.ts b/src/subcommands/admin/score-inject.ts index b775859..5133a85 100644 --- a/src/subcommands/admin/score-inject.ts +++ b/src/subcommands/admin/score-inject.ts @@ -18,11 +18,15 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction const opts = Discord.Interaction.options(interaction); 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 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 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); if (!char) { @@ -30,14 +34,19 @@ export async function handleScoreInject(interaction: ChatInputCommandInteraction return; } - const historyKey = TGKey.from({ date: dateStr, slot }); + const historyKey = TGKey.from({ date: date, slot }); Score.submit({ character: char, pts, k, d, + atk, + def, + heal, slot, + date, + playedBy: playedByArg, submittedByOfficer: true, }); diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts index 831c1bd..3b05f31 100644 --- a/src/subcommands/score/set.ts +++ b/src/subcommands/score/set.ts @@ -1,12 +1,16 @@ import { ChatInputCommandInteraction } from "discord.js"; import { Config } from "@systems/config"; 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 { replyAndDelete } from "@utils"; import { Emoji } from "@systems/emojis"; import { Discord } from "@discord"; 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 { const options = Discord.Interaction.options(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; } - await submitScore({ - userKey: borrowedFrom ?? userKey, - playedBy: borrowedFrom ? userKey : undefined, - characterName: char.name, - cls: char.class.key, - nation: char.nation, - pts: ptsArg!, + log.debug(`Submitting score: slot=${slot} (type: ${typeof slot}) date=${new Date().toISOString().slice(0,10)}`); + + await Score.submit({ + character: char, + playedBy: borrowedFrom ? userKey : undefined, + pts: ptsArg!, k, d, - slot, atk, def, heal, - submittedByOfficer: isOfficer && !!nameArg, + slot: slot as SlotHour, + submittedByOfficer: isOfficer && !!nameArg, }); const scoreEmoji = Emoji.get("score") || "📊"; diff --git a/src/subcommands/score/submitCore.ts b/src/subcommands/score/submitCore.ts index 6d906d4..b2cc600 100644 --- a/src/subcommands/score/submitCore.ts +++ b/src/subcommands/score/submitCore.ts @@ -2,7 +2,10 @@ import { Config } from "@systems/config"; import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; import { getEffectiveCharacter } from "@systems/borrow"; 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 ──────────────────────────────────────────────────────────────────── @@ -50,6 +53,8 @@ export namespace score { 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({ userKey: borrowedFrom ?? userKey, playedBy: borrowedFrom ? userKey : undefined, @@ -66,8 +71,8 @@ export namespace score { submittedByOfficer, }); - const scoreEmoji = getEmoji("score") || "📊"; - const kdEmoji = getEmoji("kd") || "⚔️"; + const scoreEmoji = Emoji.get("score") || "📊"; + const kdEmoji = Emoji.get("kd") || "⚔️"; const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : ""; const statsNote = [ atk !== undefined ? `ATK: ${atk}` : null, diff --git a/src/systems/attendance.ts b/src/systems/attendance.ts index 11e1142..5c4bc47 100644 --- a/src/systems/attendance.ts +++ b/src/systems/attendance.ts @@ -57,19 +57,21 @@ function save(): void { /** * Check if all attendees have submitted scores. */ - allSubmitted(historyKey: TGKey): boolean { - const players = _data[historyKey] ?? []; - if (players.length === 0) return false; - try { - const result = JSON.parse( - fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8") - ); - const submitted = new Set(result.scores?.map((s: any) => s.userKey) ?? []); - return players.every((p) => submitted.has(p)); - } catch { - return false; - } - }, + allSubmitted(historyKey: TGKey): boolean { + const players = _data[historyKey] ?? []; + if (players.length === 0) return false; + try { + const result = JSON.parse( + fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8") + ); + const submitted = new Set( + result.scores?.map((s: any) => s.playedBy ?? s.userKey) ?? [] + ); + return players.every((p) => submitted.has(p)); + } catch { + return false; + } + }, /** * Get all history keys (for listing past TGs). diff --git a/src/systems/history.ts b/src/systems/history.ts index 46ce31c..b594bd2 100644 --- a/src/systems/history.ts +++ b/src/systems/history.ts @@ -5,6 +5,8 @@ import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; import { Paths } from "@helpers/paths"; 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"); @@ -13,9 +15,12 @@ function historyPath(key: string): string { } export function loadResult(date: string, slot: number): TGResult | null { - const path = historyPath(TGKey.from({ date, slot })); - return Store.read(path); + const key = TGKey.from({ date, slot }); + 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 { if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true }); 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 { - const result = loadResult(score.date, score.slot) ?? { - slot: score.slot, - date: score.date, - confirmed: false, - nationKD: { - source: Nation.Procyon, - capella: { k: 0, d: 0 }, - procyon: { k: 0, d: 0 }, - }, - scores: [], - }; + let result = loadResult(score.date, score.slot); + if (!result || !result.date || !result.slot) { + result = { + slot: score.slot, + date: score.date, + confirmed: false, + nationKD: { + source: Nation.Procyon, + capella: { k: 0, d: 0 }, + procyon: { k: 0, d: 0 }, + }, + scores: [], + }; + } // Overwrite existing score for this player+slot result.scores = result.scores.filter( (s) => !(s.userKey === score.userKey && s.characterName === score.characterName && s.slot === score.slot && s.date === score.date) ); result.scores.push(score); + log.debug(`upsertScore: about to save — result.date=${result.date} result.slot=${result.slot}`); saveResult(result); } diff --git a/src/systems/leaderboard.ts b/src/systems/leaderboard.ts index 1ae7548..44a8ef3 100644 --- a/src/systems/leaderboard.ts +++ b/src/systems/leaderboard.ts @@ -105,9 +105,9 @@ if (!score) continue; totalKills += score.k ?? 0; totalDeaths += score.d ?? 0; - totalAtk += score.atk ?? 0; - totalDef += score.def ?? 0; - totalHeal += score.heal ?? 0; + totalAtk += score.stats?.atk ?? 0; + totalDef += score.stats?.def ?? 0; + totalHeal += score.stats?.heal ?? 0; } return { diff --git a/src/systems/result.ts b/src/systems/result.ts index 3e3b369..9ad9908 100644 --- a/src/systems/result.ts +++ b/src/systems/result.ts @@ -3,9 +3,9 @@ */ 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 { Score, TGScore } from "@systems/score"; + import { Score } from "@systems/score"; import { Attendance } from "@systems/attendance"; import { WRank, WRankEntry } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; @@ -24,11 +24,6 @@ // ─── Types ──────────────────────────────────────────────────────────────────── - interface NationContext { - nationHasRank: boolean; - nationHasDelta: boolean; - } - interface ResultRow { character: Character; score?: TGScore; @@ -36,196 +31,60 @@ 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 ───────────────────────────────────────────────────────────────────── function buildRows(historyKey: TGKey): ResultRow[] { - const { slot, date } = TGKey.parse(historyKey); - let players: UserKey[] = Attendance.players(historyKey); - - if (players.length === 0) { - const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey)); - if (history?.scores) { - players = [...new Set(history.scores.map((s: TGScore) => s.userKey))]; - } - } - - const weekKey = WRank.weekKey(new Date(date)); - const addedUsers = new Set(); - const rows: ResultRow[] = []; - - for (const userKey of players) { - const chars = CharacterRegistry.forUser(userKey); - - for (const char of chars) { - const score = Score.get({ character: char, slot, historyKey }); - if (!score) continue; - - 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: Leaves.countForChar({ characterName: char.name }), - }); - addedUsers.add(userKey); - break; - } - - if (!addedUsers.has(userKey)) { - const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey)); - const score = history?.scores.find((s: TGScore) => s.userKey === userKey); - if (score) { - const foundChar = CharacterRegistry.find(score.characterName); - const classKey = (typeof score.class === "object" - ? (score.class as any)?.key - : score.class) as ClassKey; - const char: Character = foundChar ?? { - 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; - } + const { slot, date } = TGKey.parse(historyKey); + let players: UserKey[] = Attendance.players(historyKey); + + if (players.length === 0) { + const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey)); + if (history?.scores) { + players = [...new Set(history.scores.map((s: TGScore) => s.playedBy ?? s.userKey))]; + } + } + + const weekKey = WRank.weekKey(new Date(date)); + const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey)); + const rows: ResultRow[] = []; + + // Match each ATTENDEE (player) directly against the scores where + // (playedBy ?? userKey) === that player — this is the authoritative + // link, not "does this player own/share a character with a score". + for (const playerKey of players) { + const score = history?.scores.find((s: TGScore) => (s.playedBy ?? s.userKey) === playerKey); + if (!score) continue; // attendee hasn't submitted yet — handled elsewhere (placeholder row) + + const foundChar = CharacterRegistry.find(score.characterName); + const classKey = (typeof score.class === "object" ? (score.class as any)?.key : score.class) as ClassKey; + const char: Character = foundChar ?? { + 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: Leaves.countForChar({ characterName: char.name }), + }); + } + + return rows; +} // ─── Namespace ──────────────────────────────────────────────────────────────── diff --git a/src/systems/score.ts b/src/systems/score.ts index ff291a4..d49162b 100644 --- a/src/systems/score.ts +++ b/src/systems/score.ts @@ -9,34 +9,32 @@ * 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 { Store } from "@systems/store"; import { Paths } from "@helpers/paths"; 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; - atk?: number; - def?: number; - heal?: number; - submittedAt: string; - slot: SlotHour; - date: string; - submittedByOfficer: boolean; - wRankAtSubmission?: { - rank: number; - delta: number; - }; - } +// 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; @@ -114,67 +112,65 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void { * Submit a score for a character. * Handles W.Rank snapshot at submission time. */ - async submit({ character, borrowedFrom, pts, k, d, atk, def, heal, slot, submittedByOfficer }: { - character: Character; - borrowedFrom?: UserKey; - pts: number; - k?: number; - d?: number; - atk?: number; - def?: number; - heal?: number; - slot: SlotHour; - submittedByOfficer?: boolean; - }): Promise { - const date = new Date().toISOString().slice(0, 10); - const historyKey = TGKey.current({ slot }); - const history = loadHistory(historyKey); - - // Snapshot W.Rank before recording score - const existingEntry = WRank.entry(character.name, character.nation); - const wRankAtSubmission = existingEntry ? { - rank: existingEntry.currentRank, - delta: existingEntry.currentRank - (existingEntry.previousRank ?? existingEntry.currentRank), - } : undefined; - - const score: TGScore = { - userKey: character.ownerKey, - playedBy: borrowedFrom, - characterName: character.name, - class: character.class.key, - nation: character.nation, - pts, - k, - d, - atk, - def, - heal, - submittedAt: new Date().toISOString(), - slot, - date, - submittedByOfficer: submittedByOfficer ?? false, - wRankAtSubmission, - }; - - // Upsert — replace existing score for same character/slot - history.scores = history.scores.filter( - (s) => !(s.userKey === character.ownerKey && - s.characterName === character.name && - s.slot === slot && - s.date === date) - ); - history.scores.push(score); - saveHistory(historyKey, history); - - // Record in W.Rank - WRank.recordScore( - character.ownerKey, - character.name, - character.class.key, - character.nation, - pts, - historyKey - ); - await RuntimeEvents.emit("scoreSubmitted", { historyKey, character }); - }, + async submit({ character, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByOfficer }: { + character: Character; + playedBy?: UserKey; + pts: number; + k?: number; + d?: number; + atk?: number; + def?: number; + heal?: number; + slot: SlotHour; + date?: string; // ← NEW, optional, defaults to today + submittedByOfficer?: boolean; + }): Promise { + const resolvedDate = date ?? new Date().toISOString().slice(0, 10); + const historyKey = TGKey.from({ date: resolvedDate, slot }); + const history = loadHistory(historyKey); + + const existingEntry = WRank.entry(character.name, character.nation); + const wRankAtSubmission = existingEntry ? { + rank: existingEntry.currentRank, + delta: existingEntry.currentRank - (existingEntry.previousRank ?? existingEntry.currentRank), + } : undefined; + + const score: TGScore = { + userKey: character.ownerKey, + playedBy: playedBy, + characterName: character.name, + class: character.class.key, + nation: character.nation, + pts, + k, + d, + stats: atk !== undefined || def !== undefined || heal !== undefined + ? { atk, def, heal } + : undefined, + submittedAt: new Date().toISOString(), + slot, + date: resolvedDate, + submittedByOfficer: submittedByOfficer ?? false, + wRankAtSubmission, + }; + + history.scores = history.scores.filter( + (s) => !(s.userKey === character.ownerKey && + s.characterName === character.name && + s.slot === slot && + s.date === resolvedDate) + ); + history.scores.push(score); + saveHistory(historyKey, history); + + WRank.recordScore( + character.ownerKey, + character.name, + character.class.key, + character.nation, + pts, + historyKey + ); + await RuntimeEvents.emit("scoreSubmitted", { historyKey, character }); + }, }; \ No newline at end of file diff --git a/src/systems/scores.ts b/src/systems/scores.ts index b442551..8c555d0 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -3,6 +3,8 @@ import { Config } from "./config"; import { upsertScore, todayString } from "./history"; import { WRank } from "./wrank"; 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 // Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" @@ -72,7 +74,9 @@ export interface ScoreSubmission { export function submitScore(sub: ScoreSubmission): void { const date = sub.date ?? todayString(); + log.debug(`sub.slot=${sub.slot} date=${date}`); const historyKey = TGKey.from({ date, slot: sub.slot }); + log.debug(`historyKey=${historyKey}`); const score: TGScore = { userKey: sub.userKey, @@ -92,6 +96,7 @@ export function submitScore(sub: ScoreSubmission): void { submittedByOfficer: sub.submittedByOfficer, }; + log.debug(`score.date=${score.date} score.slot=${score.slot}`); upsertScore(score); WRank.recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); } diff --git a/src/systems/tg.ts b/src/systems/tg.ts index 074eb2d..2510cf7 100644 --- a/src/systems/tg.ts +++ b/src/systems/tg.ts @@ -12,10 +12,10 @@ * TG.getWeeklySummary({ character }) */ - import { Nation, Character, UserKey, SlotHour } from "@types"; + import { Nation, Character, UserKey, SlotHour, TGScore } from "@types"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; - import { Score, TGScore, WeeklySummary } from "@systems/score"; + import { Score, WeeklySummary } from "@systems/score"; import { Attendance } from "@systems/attendance"; import { Config } from "@systems/config"; import { TGKey } from "@systems/tg-key"; diff --git a/src/types.ts b/src/types.ts index 842bfc9..69d7387 100644 --- a/src/types.ts +++ b/src/types.ts @@ -193,19 +193,23 @@ export interface PollState { // ─── Scores ────────────────────────────────────────────────────────────────── export interface TGScore { - userKey: string; - characterName: string; - class: ClassKey; - nation: Nation; // snapshotted at submission time - pts: number; - k?: number; - d?: number; - stats?: TGStats; - submittedAt: string; // ISO timestamp - slot: number; // TG hour - date: string; // YYYY-MM-DD + 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; - playedBy?: string; // userKey of who actually played (if borrowed) + wRankAtSubmission?: { + rank: number; + delta: number; + }; } export interface TGStats { diff --git a/src/ui/leaderboard/index.ts b/src/ui/leaderboard/index.ts index 896198b..b0f769e 100644 --- a/src/ui/leaderboard/index.ts +++ b/src/ui/leaderboard/index.ts @@ -9,6 +9,7 @@ import { Runtime } from "@systems/runtime"; import path from "path"; import fs from "fs"; +import { TGStats } from "@root/src/types"; const log = Logger.for("leaderboard-ui"); @@ -28,7 +29,7 @@ tgCount: number; totalKills: number; totalDeaths: number; - stats?: { atk?: number; def?: number; heal?: number }; + stats?: TGStats; position?: { currentRank: number; previousRank?: number }; leavesCount: number; } diff --git a/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts b/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts index 0bf58eb..f113010 100644 --- a/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts +++ b/src/ui/leaderboard/layouts/horizontal-sequential-extra-stats.ts @@ -79,7 +79,7 @@ const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText)); const atkText = format.statText("anima_atk", row.stats.atk, "⚔️"); 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)}`; diff --git a/src/ui/result/index.ts b/src/ui/result/index.ts index 6cd8d6a..ca90294 100644 --- a/src/ui/result/index.ts +++ b/src/ui/result/index.ts @@ -8,6 +8,7 @@ import { Config } from "@systems/config"; import { Logger } from "@systems/logger"; import { Runtime } from "@systems/runtime"; + import { TGStats } from "@root/src/types"; import path from "path"; import fs from "fs"; @@ -29,9 +30,7 @@ pts: number; k?: number; d?: number; - atk?: number; - def?: number; - heal?: number; + stats?: TGStats; wRankAtSubmission?: { rank: number; delta: number }; }; position?: { currentRank: number; previousRank?: number }; diff --git a/src/ui/result/layouts/default.ts b/src/ui/result/layouts/default.ts index c8a42de..f9962cf 100644 --- a/src/ui/result/layouts/default.ts +++ b/src/ui/result/layouts/default.ts @@ -32,13 +32,7 @@ const mainLine = Layout.formatRow(TEMPLATE, tokens); - const statsStr = row.score - ? 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 statsStr = row.score ? format.stats(row.score.stats) : ""; return statsStr ? `${mainLine}\n┕ ${statsStr}` : mainLine; } diff --git a/src/ui/result/layouts/inline.ts b/src/ui/result/layouts/inline.ts index 669be43..b2d04b0 100644 --- a/src/ui/result/layouts/inline.ts +++ b/src/ui/result/layouts/inline.ts @@ -19,13 +19,7 @@ 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 statsStr = row.score - ? 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 statsStr = row.score ? format.stats(row.score.stats) : ""; const tokens: Record = { wrank: Layout.wrank(wrEntry, goal, context), diff --git a/src/ui/result/layouts/sequential.ts b/src/ui/result/layouts/sequential.ts index c8efb3d..c522ed0 100644 --- a/src/ui/result/layouts/sequential.ts +++ b/src/ui/result/layouts/sequential.ts @@ -1,8 +1,8 @@ /** - * Sequential result layout — same alignment technique as the - * leaderboard's "sequential" layout: name, score, and K/D padded via - * TextAlign, computed per-nation. Secondary stats (atk/def/heal) shown - * on an indented second line when present. + * Sequential result layout — name, score, and K/D padded via TextAlign, + * computed per-nation. Secondary stats (atk/def/heal) read from the + * nested TGStats shape (row.score.stats), shown on an indented second + * line when present, column-locked under score/kd. */ import { EmbedBuilder } from "discord.js"; @@ -18,14 +18,11 @@ 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 SCORE_GAP = 4; const DEF_GAP = 1; const HEAL_GAP = 4; - const DEF_WIDTH_OFFSET = -0.1; // negative width units, pulls def slightly left - + function formatRow( row: ResultRow, context: NationContext, @@ -44,8 +41,6 @@ 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) : "—"; - // Column targets combine primary + secondary stat widths, so the main - // row makes room for the stats line beneath it. const scoreColumn = [...allScores, ...allAtks]; const kdColumn = [...allKds, ...allDefs]; @@ -60,16 +55,19 @@ 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; const prefixText = `${tokens.wrank} ${tokens.class} ${tokens.name}${tokens.indicators}`; const prefixGap = TextAlign.padLeft("", TextAlign.estimateWidth(prefixText)); - const atkText = format.statText("anima_atk", row.score!.atk, "⚔️"); - const defText = format.statText("anima_def", row.score!.def, "🛡️"); - const healText = format.statText("anima_massheal", row.score!.heal, "💚"); + 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.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}`; } @@ -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 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 defEmoji = Emoji.get("def") || "🛡️"; - const capellaAtks = sortedCapella.map((r) => r.score?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.atk)}` : ""); - const procyonAtks = sortedProcyon.map((r) => r.score?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.atk)}` : ""); - const capellaDefs = sortedCapella.map((r) => r.score?.def ? `${defEmoji} ${format.number.abbrev(r.score.def)}` : ""); - const procyonDefs = sortedProcyon.map((r) => r.score?.def ? `${defEmoji} ${format.number.abbrev(r.score.def)}` : ""); + const atkEmoji = Emoji.get("anima_atk") || "⚔️"; + const defEmoji = Emoji.get("anima_def") || "🛡️"; + const capellaAtks = sortedCapella.map((r) => r.score?.stats?.atk ? `${atkEmoji} ${format.number.abbrev(r.score.stats.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?.stats?.def ? `${defEmoji} ${format.number.abbrev(r.score.stats.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 capD = capellaRows.reduce((s, r) => s + (r.score?.d ?? 0), 0); @@ -128,7 +126,7 @@ export const sequentialResultLayout: ResultLayout = { 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, formatRow: formatRow as any, }; \ No newline at end of file