From 1ad2eb6cee99f95c503ce6b18f4b4b2c52f623f2 Mon Sep 17 00:00:00 2001 From: tuggernuts1123 <79392084+tuggernuts1123@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:30:05 -0500 Subject: [PATCH 1/2] Add /stats career-stats page Public IP-resolved page (same flow as /namechange) showing the visitor's career move usage, accuracy, parries, ringouts, and movement stats from their PlayerStats.aggregate document. - New /stats route in server.ts: resolves player by IP via the existing resolvePlayerForWeb helper (renders the account picker if multiple accounts share an IP), loads PlayerStats by account_id, returns a rendered page with the aggregate JSON inlined. - New static page src/static/my_stats.html with the moves table (sorted by Used desc, computed Hit % and % of all client-side) plus defense / damage / movement summary cards. Charge Attack shows its own row but is excluded from the % of all denominator. - "My Stats" tile added to /home. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server.ts | 46 +++++ src/static/home.html | 4 + src/static/my_stats.html | 385 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 435 insertions(+) create mode 100644 src/static/my_stats.html diff --git a/src/server.ts b/src/server.ts index 8082e73..adf006c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -54,6 +54,7 @@ import { GAME_SERVER_PORT } from "./game/udp"; import { sscRouter } from "./ssc/routes"; import { getCurrentCRC, LoadConfig, MATCHMAKING_CRC } from "./data/config"; import { PlayerTester, PlayerTesterModel } from "./database/PlayerTester"; +import { PlayerStatsModel } from "./database/PlayerStats"; import { Types } from "mongoose"; import { RegExpMatcher, TextCensor, englishDataset, englishRecommendedTransformers, asteriskCensorStrategy } from "obscenity"; import env from "./env/env"; @@ -239,6 +240,10 @@ const homeFilePath = path.join(__dirname, "static/home.html"); const homeSource = fs.readFileSync(homeFilePath, "utf8"); const homeTemplate = handlebars.compile(homeSource); +const myStatsFilePath = path.join(__dirname, "static/my_stats.html"); +const myStatsSource = fs.readFileSync(myStatsFilePath, "utf8"); +const myStatsTemplate = handlebars.compile(myStatsSource); + const adminBannerFilePath = path.join(__dirname, "static/admin.html"); const adminBannerSource = fs.readFileSync(adminBannerFilePath, "utf8"); const adminBannerTemplate = handlebars.compile(adminBannerSource); @@ -253,6 +258,47 @@ app.get("/home", async (req, res) => { } }); +// ── /stats — career-stats page for the caller's IP-resolved account ── +// Mirrors the /namechange flow: caller's IP → resolvePlayerForWeb → PlayerStats. +// If multiple accounts at the IP, the account picker is shown. +// Public, no auth. +app.get("/stats", async (req, res) => { + try { + const { player, pickerShown } = await resolvePlayerForWeb(req, res, "/stats"); + if (pickerShown) return; + + if (!player) { + const html = myStatsTemplate({ hasStats: false }); + res.send(html); + return; + } + + const accountId = String((player as any)._id); + const stats = await PlayerStatsModel.findOne({ account_id: accountId }).lean(); + + if (!stats || !stats.aggregate || Object.keys(stats.aggregate).length === 0) { + const html = myStatsTemplate({ hasStats: false, playerName: (player as any).name || "" }); + res.send(html); + return; + } + + // Stringify the aggregate as JSON for the inline + + {{/if}} + + From b5624be34655a2ef36946c6c565b2fc1233cacf7 Mon Sep 17 00:00:00 2001 From: tuggernuts1123 <79392084+tuggernuts1123@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:35:27 -0500 Subject: [PATCH 2/2] Extract renderStatCards helper for stat-card grids Sourcery feedback: the defense / damage / movement grids each duplicated the same loop that built .stat_card elements. Centralize it in a single renderStatCards(containerId, cards) helper. Bonus: switch from innerHTML to textContent for the label and value elements so future data shifts can't inject markup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/static/my_stats.html | 55 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/static/my_stats.html b/src/static/my_stats.html index e2d8ed7..b684a57 100644 --- a/src/static/my_stats.html +++ b/src/static/my_stats.html @@ -302,8 +302,29 @@

No stats yet

const projBlocked = num("totalProjectilesBlocked"); const knockMit = num("totalKnockbackMitigated"); - const defenseGrid = document.getElementById("defense_grid"); - const defCards = [ + // Shared helper: render an array of [label, value] pairs as .stat_card + // tiles inside the given container element. Use textContent (not + // innerHTML) so labels/values can't accidentally inject markup if the + // upstream data ever changes shape. + function renderStatCards(containerId, cards) { + const container = document.getElementById(containerId); + if (!container) return; + for (const [label, value] of cards) { + const card = document.createElement("div"); + card.className = "stat_card"; + const labelEl = document.createElement("div"); + labelEl.className = "label"; + labelEl.textContent = label; + const valueEl = document.createElement("div"); + valueEl.className = "value"; + valueEl.textContent = value; + card.appendChild(labelEl); + card.appendChild(valueEl); + container.appendChild(card); + } + } + + renderStatCards("defense_grid", [ ["Parries Landed", fmt(parries)], ["Damage Parried", fmt(damageParried)], ["Dodges Used", fmt(totalDodges)], @@ -313,16 +334,9 @@

No stats yet

["Armor Hits Taken", fmt(armorHits)], ["Armor Breaks", fmt(armorBreaks)], ["Knockback Mitigated", fmt(knockMit)], - ]; - for (const [label, value] of defCards) { - const c = document.createElement("div"); - c.className = "stat_card"; - c.innerHTML = '
' + label + '
' + value + '
'; - defenseGrid.appendChild(c); - } + ]); // ─── Damage / Ringouts grid ──────────────────────────────────── - const damageGrid = document.getElementById("damage_grid"); const dmgDealt = num("totalDamageDealt"); const dmgTaken = num("totalDamageTaken"); const ringouts = num("totalRingouts"); @@ -334,7 +348,7 @@

No stats yet

const spikeKO = num("totalDownspikeRingouts"); const ringoutDiff = ringouts - ringoutsRcv; - const dmgCards = [ + renderStatCards("damage_grid", [ ["Damage Dealt", fmt(dmgDealt)], ["Damage Taken", fmt(dmgTaken)], ["Ringouts Scored", fmt(ringouts)], @@ -345,16 +359,9 @@

No stats yet

["KOs vs Low-% Enemies", fmt(koLowPct)], ["Projectile KOs", fmt(projKO)], ["Downspike KOs", fmt(spikeKO)], - ]; - for (const [label, value] of dmgCards) { - const c = document.createElement("div"); - c.className = "stat_card"; - c.innerHTML = '
' + label + '
' + value + '
'; - damageGrid.appendChild(c); - } + ]); // ─── Movement grid ───────────────────────────────────────────── - const movementGrid = document.getElementById("movement_grid"); const jumps = num("totalJumps"); const doubleJumps = num("totalDoubleJumps"); const airTime = num("totalAirTime"); @@ -363,7 +370,7 @@

No stats yet

const platDrops = num("totalPlatformDropThroughs"); const taunts = num("totalTauntsUsed"); - const moveCards = [ + renderStatCards("movement_grid", [ ["Total Jumps", fmt(jumps)], ["Double Jumps", fmt(doubleJumps)], ["Air Time (s)", fmt(Math.round(airTime))], @@ -371,13 +378,7 @@

No stats yet

["Wall Hang Time (s)", fmt(Math.round(wallHang))], ["Platform Drops", fmt(platDrops)], ["Taunts", fmt(taunts)], - ]; - for (const [label, value] of moveCards) { - const c = document.createElement("div"); - c.className = "stat_card"; - c.innerHTML = '
' + label + '
' + value + '
'; - movementGrid.appendChild(c); - } + ]); })(); {{/if}}