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:
Nuno Duque Nunes 2026-06-22 04:27:03 +01:00
parent da0f90f5d7
commit 049ea7b77f
23 changed files with 326 additions and 381 deletions

3
data/emojis/circle.json Normal file
View file

@ -0,0 +1,3 @@
{
"circle_massheal_purple": "<:circle_massheal_purple:1518287829648801904>"
}

View 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": []
}

View file

@ -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>"
}

View file

@ -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 {

View file

@ -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

View file

@ -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);

View file

@ -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,
});

View file

@ -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<void> {
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
@ -47,19 +51,18 @@ 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,
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,
characterName: char.name,
cls: char.class.key,
nation: char.nation,
pts: ptsArg!,
k,
d,
slot,
atk,
def,
heal,
slot: slot as SlotHour,
submittedByOfficer: isOfficer && !!nameArg,
});

View file

@ -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,

View file

@ -64,7 +64,9 @@ function save(): void {
const result = JSON.parse(
fs.readFileSync(Paths.data("tg-history", `${historyKey}.json`), "utf8")
);
const submitted = new Set(result.scores?.map((s: any) => s.userKey) ?? []);
const submitted = new Set(
result.scores?.map((s: any) => s.playedBy ?? s.userKey) ?? []
);
return players.every((p) => submitted.has(p));
} catch {
return false;

View file

@ -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,7 +28,9 @@ export function saveResult(result: TGResult): void {
}
export function upsertScore(score: TGScore): void {
const result = loadResult(score.date, score.slot) ?? {
let result = loadResult(score.date, score.slot);
if (!result || !result.date || !result.slot) {
result = {
slot: score.slot,
date: score.date,
confirmed: false,
@ -34,12 +41,14 @@ export function upsertScore(score: TGScore): void {
},
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);
}

View file

@ -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 {

View file

@ -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,119 +31,6 @@
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[] {
@ -158,20 +40,30 @@
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))];
players = [...new Set(history.scores.map((s: TGScore) => s.playedBy ?? s.userKey))];
}
}
const weekKey = WRank.weekKey(new Date(date));
const addedUsers = new Set<UserKey>();
const history = Store.read<{ scores: TGScore[] }>(TGKey.toHistoryPath(historyKey));
const rows: ResultRow[] = [];
for (const userKey of players) {
const chars = CharacterRegistry.forUser(userKey);
// 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)
for (const char of chars) {
const score = Score.get({ character: char, slot, historyKey });
if (!score) continue;
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
@ -189,43 +81,10 @@
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;
}
}
// ─── Namespace ────────────────────────────────────────────────────────────────

View file

@ -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,9 +112,9 @@ 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 }: {
async submit({ character, playedBy, pts, k, d, atk, def, heal, slot, date, submittedByOfficer }: {
character: Character;
borrowedFrom?: UserKey;
playedBy?: UserKey;
pts: number;
k?: number;
d?: number;
@ -124,13 +122,13 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
def?: number;
heal?: number;
slot: SlotHour;
date?: string; // ← NEW, optional, defaults to today
submittedByOfficer?: boolean;
}): Promise<void> {
const date = new Date().toISOString().slice(0, 10);
const historyKey = TGKey.current({ slot });
const resolvedDate = date ?? new Date().toISOString().slice(0, 10);
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 wRankAtSubmission = existingEntry ? {
rank: existingEntry.currentRank,
@ -139,34 +137,32 @@ function saveHistory(historyKey: TGKey, data: { scores: TGScore[] }): void {
const score: TGScore = {
userKey: character.ownerKey,
playedBy: borrowedFrom,
playedBy: playedBy,
characterName: character.name,
class: character.class.key,
nation: character.nation,
pts,
k,
d,
atk,
def,
heal,
stats: atk !== undefined || def !== undefined || heal !== undefined
? { atk, def, heal }
: undefined,
submittedAt: new Date().toISOString(),
slot,
date,
date: resolvedDate,
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)
s.date === resolvedDate)
);
history.scores.push(score);
saveHistory(historyKey, history);
// Record in W.Rank
WRank.recordScore(
character.ownerKey,
character.name,

View file

@ -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);
}

View file

@ -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";

View file

@ -193,19 +193,23 @@ export interface PollState {
// ─── Scores ──────────────────────────────────────────────────────────────────
export interface TGScore {
userKey: string;
userKey: UserKey;
playedBy?: UserKey; // if borrowed
characterName: string;
class: ClassKey;
nation: Nation; // snapshotted at submission time
class: string;
nation: Nation;
pts: number;
k?: number;
d?: number;
stats?: TGStats;
submittedAt: string; // ISO timestamp
slot: number; // TG hour
date: string; // YYYY-MM-DD
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 {

View file

@ -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;
}

View file

@ -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)}`;

View file

@ -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 };

View file

@ -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;
}

View file

@ -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<string, string> = {
wrank: Layout.wrank(wrEntry, goal, context),

View file

@ -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,13 +18,10 @@
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,
@ -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,
};