From 5ebc09a598a5dffe3c035302be5d057ad2370db4 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:03:13 +0100 Subject: [PATCH 01/14] feat(shared): optional FFA rating fields in API schemas Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 70 ++++++++++ tests/ApiSchemas.test.ts | 153 +++++++++++++++++++--- tests/client/ApiPlayerLeaderboard.test.ts | 16 +++ 3 files changed, 224 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 14ebcdacea7..37212053db7 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -276,6 +276,28 @@ export type GameXpEligible = z.infer; export type GameXpIneligible = z.infer; export type GameXpResponse = z.infer; +// GET /game/:id/ffa-rating — what one FFA game did to the caller's rating. +// While calibrating (`calibrating: true`) only the games count is sent; once +// rated, the before/after numbers and tiers follow. Strict, unlike the fields +// added to the lists above: the caller treats a body that fails to parse as +// "unavailable", which is the right outcome for a drifted response. The +// endpoint 404s until the API serves the FFA rating (FFA_RATING=live). +export const FfaGameRatingResponseSchema = z.object({ + gameId: z.string(), + calibrating: z.boolean(), + revealed: z.boolean().optional(), + calibrationGames: z.number().int().min(0).max(10), + ratingBefore: z.number().optional(), + ratingAfter: z.number().optional(), + uncertaintyAfter: z.number().optional(), + tierBefore: z.string().optional(), + tierAfter: z.string().optional(), + expected: z.number().optional(), + deltaResult: z.number().optional(), + deltaPlace: z.number().optional(), +}); +export type FfaGameRatingResponse = z.infer; + // GET /public/player/:publicId/progress — another player's level. No auth. export const PublicProgressSchema = z.object({ prestige: z.number(), @@ -624,6 +646,20 @@ export const UserMeResponseSchema = z.object({ ffa: z .object({ elo: z.number().optional(), + // Absent until the API serves the FFA rating (FFA_RATING=live); a + // malformed value reads as absent. + uncertainty: z.number().optional().catch(undefined), + tier: z.string().optional().catch(undefined), + games: z.number().int().nonnegative().optional().catch(undefined), + wins: z.number().int().nonnegative().optional().catch(undefined), + peak: z.number().optional().catch(undefined), + calibrationGames: z + .number() + .int() + .min(0) + .max(10) + .optional() + .catch(undefined), }) .optional(), }) @@ -1155,6 +1191,17 @@ export const PlayerStatsTreeSchema = z.object({ }); export type PlayerStatsTree = z.infer; +// A player's FFA rating on their public profile. +export const PublicFfaRatingSchema = z.object({ + rating: z.number(), + uncertainty: z.number(), + tier: z.string(), + games: z.number().int().nonnegative(), + wins: z.number().int().nonnegative(), + peak: z.number(), +}); +export type PublicFfaRating = z.infer; + export const PlayerProfileSchema = z.object({ createdAt: z.iso.datetime(), user: DiscordUserSchema.optional(), @@ -1186,6 +1233,10 @@ export const PlayerProfileSchema = z.object({ // games has one. Optional so responses from an API without the field still // parse (→ no tag shown). displayClanTag: z.string().nullable().optional(), + // null = unrated (calibrating or never played FFA). + // Absent until the API serves the FFA rating (FFA_RATING=live); a malformed + // value reads as absent. + ffaRating: PublicFfaRatingSchema.nullable().optional().catch(undefined), }); export type PlayerProfile = z.infer; @@ -1213,6 +1264,16 @@ export const PlayerGameResultSchema = z.enum([ ]); export type PlayerGameResult = z.infer; +// What one FFA game did to the player's rating, for the history list. +export const FfaRatingGameDeltaSchema = z.object({ + before: z.number(), + after: z.number(), + expected: z.number(), + deltaResult: z.number(), + deltaPlace: z.number(), +}); +export type FfaRatingGameDelta = z.infer; + export const PublicPlayerGameSchema = z.object({ gameId: z.string(), start: z.iso.datetime(), @@ -1226,6 +1287,11 @@ export const PublicPlayerGameSchema = z.object({ totalPlayers: z.number().int().nonnegative().nullable(), username: z.string(), clanTag: z.string().nullable(), + // null = this game did not move the rating (not a rated FFA game, or still + // calibrating). + // Absent until the API serves the FFA rating (FFA_RATING=live); a malformed + // value reads as absent. + ffaRating: FfaRatingGameDeltaSchema.nullable().optional().catch(undefined), }); export type PublicPlayerGame = z.infer; @@ -1276,6 +1342,10 @@ export const RankedLeaderboardEntrySchema = z.object({ // Account username (null = never set). The client displays // `accountUsername ?? public_id`. accountUsername: z.string().nullable(), + // Absent until the API serves the FFA rating (FFA_RATING=live); a malformed + // value reads as absent. + uncertainty: z.number().optional().catch(undefined), + tier: z.string().optional().catch(undefined), ...RowLevelFields, }); export type RankedLeaderboardEntry = z.infer< diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index ed70f881358..d3262a01512 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -2,6 +2,7 @@ import { ClaimAllRewardsResponseSchema, ClaimRewardHeldResponseSchema, ClaimRewardResponseSchema, + FfaGameRatingResponseSchema, FriendEntrySchema, GetMyTribeNamesResponseSchema, GoogleUser, @@ -532,22 +533,22 @@ describe("PlayerGameResultSchema", () => { }); }); -describe("PublicPlayerGameSchema", () => { - const validGame = { - gameId: "g1", - start: "2024-06-01T00:00:00.000Z", - durationSeconds: 1234, - map: "World", - mode: "Team", - type: "Public", - playerTeams: "Duos", - rankedType: "unranked", - result: "victory" as const, - totalPlayers: 8, - username: "alice", - clanTag: "ABC", - }; +const validGame = { + gameId: "g1", + start: "2024-06-01T00:00:00.000Z", + durationSeconds: 1234, + map: "World", + mode: "Team", + type: "Public", + playerTeams: "Duos", + rankedType: "unranked", + result: "victory" as const, + totalPlayers: 8, + username: "alice", + clanTag: "ABC", +}; +describe("PublicPlayerGameSchema", () => { it("accepts a fully-populated game", () => { expect(PublicPlayerGameSchema.safeParse(validGame).success).toBe(true); }); @@ -1740,3 +1741,125 @@ describe("UserMeResponseSchema player achievements", () => { expect(parsed.player).toEqual([]); }); }); + +describe("FFA rating fields", () => { + const ranked = { + rank: 1, + elo: 2012.6, + peakElo: 2050, + wins: 9, + losses: 31, + total: 40, + public_id: "abc123", + accountUsername: "bob.4821", + }; + const meFfa = + UserMeResponseSchema.shape.player.shape.leaderboard.unwrap().shape.ffa; + + it("keeps a board entry's uncertainty and tier", () => { + expect( + RankedLeaderboardEntrySchema.parse({ + ...ranked, + uncertainty: 61.2, + tier: "Diamond II", + }), + ).toMatchObject({ uncertainty: 61.2, tier: "Diamond II" }); + }); + + it("parses a board entry without them (API not live)", () => { + const e = RankedLeaderboardEntrySchema.parse(ranked); + expect(e.tier).toBeUndefined(); + expect(e.uncertainty).toBeUndefined(); + }); + + it("drops a malformed tier instead of rejecting the entry", () => { + const e = RankedLeaderboardEntrySchema.parse({ ...ranked, tier: 7 }); + expect(e.tier).toBeUndefined(); + expect(e.public_id).toBe("abc123"); + }); + + it("parses @me's frozen, calibrating and rated FFA shapes", () => { + expect(meFfa.parse({ elo: 1234 })).toEqual({ elo: 1234 }); + expect( + meFfa.parse({ games: 3, wins: 1, calibrationGames: 3 }), + ).toMatchObject({ games: 3, wins: 1, calibrationGames: 3 }); + expect( + meFfa.parse({ + elo: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + games: 40, + wins: 5, + peak: 1700, + }), + ).toMatchObject({ tier: "Gold I", peak: 1700 }); + }); + + it("drops an out-of-range calibrationGames", () => { + expect( + meFfa.parse({ games: 3, wins: 1, calibrationGames: 12 })! + .calibrationGames, + ).toBeUndefined(); + }); + + it("parses a history game with, without and with a null ffaRating", () => { + const delta = { + before: 1600.4, + after: 1612.6, + expected: 0.0623, + deltaResult: 9.1, + deltaPlace: 3.1, + }; + expect( + PublicPlayerGameSchema.parse({ ...validGame, ffaRating: delta }) + .ffaRating, + ).toEqual(delta); + expect( + PublicPlayerGameSchema.parse({ ...validGame, ffaRating: null }).ffaRating, + ).toBeNull(); + expect(PublicPlayerGameSchema.parse(validGame).ffaRating).toBeUndefined(); + }); + + it("parses a profile's ffaRating, and null", () => { + const r = { + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + games: 40, + wins: 5, + peak: 1700, + }; + const base = { createdAt: "2024-01-15T12:00:00.000Z", stats: {} }; + expect( + PlayerProfileSchema.parse({ ...base, ffaRating: r }).ffaRating, + ).toEqual(r); + expect( + PlayerProfileSchema.parse({ ...base, ffaRating: null }).ffaRating, + ).toBeNull(); + }); + + it("parses the per-game response while calibrating and once rated", () => { + expect( + FfaGameRatingResponseSchema.safeParse({ + gameId: "g1", + calibrating: true, + calibrationGames: 4, + }).success, + ).toBe(true); + expect( + FfaGameRatingResponseSchema.parse({ + gameId: "g1", + calibrating: false, + calibrationGames: 10, + ratingBefore: 1600.4, + ratingAfter: 1612.6, + uncertaintyAfter: 84.7, + tierBefore: "Gold I", + tierAfter: "Platinum III", + expected: 0.0623, + deltaResult: 9.1, + deltaPlace: 3.1, + }).tierAfter, + ).toBe("Platinum III"); + }); +}); diff --git a/tests/client/ApiPlayerLeaderboard.test.ts b/tests/client/ApiPlayerLeaderboard.test.ts index 9ea01c540c1..d586bfa3dba 100644 --- a/tests/client/ApiPlayerLeaderboard.test.ts +++ b/tests/client/ApiPlayerLeaderboard.test.ts @@ -53,6 +53,22 @@ describe("fetchPlayerLeaderboard", () => { expect((result as { "1v1": unknown[] })["1v1"]).toHaveLength(1); }); + it("keeps an FFA entry's uncertainty and tier", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce( + res({ + "1v1": [], + "2v2": [], + ffa: [{ ...entry(1), uncertainty: 61.2, tier: "Diamond II" }], + }), + ); + + const result = (await fetchPlayerLeaderboard(1)) as { ffa: unknown[] }; + expect(result.ffa[0]).toMatchObject({ + uncertainty: 61.2, + tier: "Diamond II", + }); + }); + it("requests the page it was asked for", async () => { const fetchMock = global.fetch as ReturnType; fetchMock.mockResolvedValueOnce(res({ "1v1": [], "2v2": [] })); From 74fe144cb2e4dd96ccbbe1b71b8af98299612a84 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:05:59 +0100 Subject: [PATCH 02/14] feat(client): FFA tier parsing and rating formatting Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 12 +++ src/client/components/ffaRating/FfaTier.ts | 96 ++++++++++++++++++++++ tests/client/FfaTier.test.ts | 58 +++++++++++++ tests/util/FfaTiers.ts | 27 ++++++ 4 files changed, 193 insertions(+) create mode 100644 src/client/components/ffaRating/FfaTier.ts create mode 100644 tests/client/FfaTier.test.ts create mode 100644 tests/util/FfaTiers.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 07a689be365..37d28010fe4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -928,6 +928,18 @@ "minimize": "Minimize", "open_on_twitch": "Open {channel} on Twitch" }, + "ffa_rating": { + "tier": { + "bronze": "Bronze", + "diamond": "Diamond", + "gold": "Gold", + "grandmaster": "Grandmaster", + "iron": "Iron", + "master": "Master", + "platinum": "Platinum", + "silver": "Silver" + } + }, "free_play": { "account": "Your account, stats, friends and cosmetics", "ad_free_steam": "No ads in the Steam app, ever", diff --git a/src/client/components/ffaRating/FfaTier.ts b/src/client/components/ffaRating/FfaTier.ts new file mode 100644 index 00000000000..2d106ba5cc3 --- /dev/null +++ b/src/client/components/ffaRating/FfaTier.ts @@ -0,0 +1,96 @@ +import { translateText } from "../../Utils"; + +/** Games a player must finish before OFstats gives them a tier. */ +export const FFA_CALIBRATION_GAMES = 10; + +export const FFA_TIER_BASES = [ + "iron", + "bronze", + "silver", + "gold", + "platinum", + "diamond", + "master", + "grandmaster", +] as const; +export type FfaTierBase = (typeof FFA_TIER_BASES)[number]; + +export type FfaDivision = "I" | "II" | "III"; + +export interface ParsedFfaTier { + base: FfaTierBase; + division: FfaDivision | null; +} + +// Literal keys, so the translation-usage test sees them used. +export const FFA_TIER_NAME_KEYS: Record = { + iron: "ffa_rating.tier.iron", + bronze: "ffa_rating.tier.bronze", + silver: "ffa_rating.tier.silver", + gold: "ffa_rating.tier.gold", + platinum: "ffa_rating.tier.platinum", + diamond: "ffa_rating.tier.diamond", + master: "ffa_rating.tier.master", + grandmaster: "ffa_rating.tier.grandmaster", +}; + +const DIVISION_RANK: Record = { III: 0, II: 1, I: 2 }; + +const DIVIDED_BASES = FFA_TIER_BASES.slice(0, 7); +const DIVISIONS: readonly string[] = ["I", "II", "III"]; + +/** + * Parses an OFstats tier string: " " for the first seven + * bases, exactly "Grandmaster" for the last. Anything else is null. + */ +export function parseFfaTier(tier: string): ParsedFfaTier | null { + if (tier === "Grandmaster") return { base: "grandmaster", division: null }; + const parts = tier.split(" "); + if (parts.length !== 2 || !DIVISIONS.includes(parts[1])) return null; + const base = DIVIDED_BASES.find( + (b) => b[0].toUpperCase() + b.slice(1) === parts[0], + ); + if (base === undefined) return null; + return { base, division: parts[1] as FfaDivision }; +} + +export function ffaTierLabel(tier: ParsedFfaTier): string { + const name = translateText(FFA_TIER_NAME_KEYS[tier.base]); + return tier.division === null ? name : `${name} ${tier.division}`; +} + +/** The translated label, or the raw string when this build doesn't know it. */ +export function ffaTierLabelOf(tier: string): string { + const parsed = parseFfaTier(tier); + return parsed === null ? tier : ffaTierLabel(parsed); +} + +/** 0 (Iron III) … 21 (Grandmaster); null for an unknown tier. */ +export function ffaTierRank(tier: string): number | null { + const parsed = parseFfaTier(tier); + if (parsed === null) return null; + const baseIndex = FFA_TIER_BASES.indexOf(parsed.base); + if (parsed.division === null) return baseIndex * 3; + return baseIndex * 3 + DIVISION_RANK[parsed.division]; +} + +export function formatRating(n: number): string { + return String(Math.round(n)); +} + +export function formatRatingWithUncertainty( + rating: number, + uncertainty: number, +): string { + return `${formatRating(rating)} ± ${formatRating(uncertainty)}`; +} + +export function formatRatingChange(n: number): string { + const r = Math.round(n); + if (r === 0) return "0"; + return r > 0 ? `+${r}` : String(r); +} + +export function formatExpectedPct(p: number): string { + return (p * 100).toFixed(1); +} diff --git a/tests/client/FfaTier.test.ts b/tests/client/FfaTier.test.ts new file mode 100644 index 00000000000..10037eb0028 --- /dev/null +++ b/tests/client/FfaTier.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +vi.mock("../../src/client/Utils", () => ({ translateText: (k: string) => k })); +import { + ffaTierLabelOf, + ffaTierRank, + formatExpectedPct, + formatRating, + formatRatingChange, + formatRatingWithUncertainty, + parseFfaTier, +} from "../../src/client/components/ffaRating/FfaTier"; +import { ALL_FFA_TIERS as ALL_TIERS } from "../util/FfaTiers"; + +describe("FfaTier", () => { + it("parses all 22 tiers", () => { + expect(ALL_TIERS).toHaveLength(22); + expect(parseFfaTier("Diamond II")).toEqual({ + base: "diamond", + division: "II", + }); + expect(parseFfaTier("Grandmaster")).toEqual({ + base: "grandmaster", + division: null, + }); + for (const t of ALL_TIERS) expect(parseFfaTier(t)).not.toBeNull(); + }); + + it.each([ + "", + "Diamond", + "Diamond IV", + "Grandmaster I", + "Mythril II", + "diamond ii", + ])("rejects %j", (t) => { + expect(parseFfaTier(t)).toBeNull(); + }); + + it("orders tiers from Iron III to Grandmaster", () => { + expect(ALL_TIERS.map(ffaTierRank)).toEqual([...Array(22).keys()]); + expect(ffaTierRank("Mythril II")).toBeNull(); + }); + + it("labels with the translated base and the roman division", () => { + expect(ffaTierLabelOf("Diamond II")).toBe("ffa_rating.tier.diamond II"); + expect(ffaTierLabelOf("Grandmaster")).toBe("ffa_rating.tier.grandmaster"); + expect(ffaTierLabelOf("Mythril II")).toBe("Mythril II"); + }); + + it("rounds and signs numbers", () => { + expect(formatRating(1612.6)).toBe("1613"); + expect(formatRatingWithUncertainty(1612.6, 84.7)).toBe("1613 ± 85"); + expect(formatRatingChange(12.4)).toBe("+12"); + expect(formatRatingChange(-3.6)).toBe("-4"); + expect(formatRatingChange(-0.4)).toBe("0"); + expect(formatExpectedPct(0.0623)).toBe("6.2"); + }); +}); diff --git a/tests/util/FfaTiers.ts b/tests/util/FfaTiers.ts new file mode 100644 index 00000000000..c09f1aefe32 --- /dev/null +++ b/tests/util/FfaTiers.ts @@ -0,0 +1,27 @@ +// The 22 OFstats tier strings in ascending order (Iron III … Grandmaster). +// Test fixture only: the client never ships this list, the tier always +// comes from the API. +export const ALL_FFA_TIERS: readonly string[] = [ + "Iron III", + "Iron II", + "Iron I", + "Bronze III", + "Bronze II", + "Bronze I", + "Silver III", + "Silver II", + "Silver I", + "Gold III", + "Gold II", + "Gold I", + "Platinum III", + "Platinum II", + "Platinum I", + "Diamond III", + "Diamond II", + "Diamond I", + "Master III", + "Master II", + "Master I", + "Grandmaster", +]; From 3c9bff2ad387b3e6c0d64c6f472807018ef1c761 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:08:12 +0100 Subject: [PATCH 03/14] feat(client): placeholder FFA tier badge and OFstats credit Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 2 + .../components/ffaRating/FfaTierBadge.ts | 112 ++++++++++++++++++ .../components/ffaRating/OfstatsCredit.ts | 31 +++++ tests/client/FfaTierBadge.test.ts | 88 ++++++++++++++ tests/client/OfstatsCredit.test.ts | 25 ++++ 5 files changed, 258 insertions(+) create mode 100644 src/client/components/ffaRating/FfaTierBadge.ts create mode 100644 src/client/components/ffaRating/OfstatsCredit.ts create mode 100644 tests/client/FfaTierBadge.test.ts create mode 100644 tests/client/OfstatsCredit.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 37d28010fe4..4ecfe135465 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -929,6 +929,8 @@ "open_on_twitch": "Open {channel} on Twitch" }, "ffa_rating": { + "calibrating": "Calibrating {n}/10", + "credit": "Rating by OFstats", "tier": { "bronze": "Bronze", "diamond": "Diamond", diff --git a/src/client/components/ffaRating/FfaTierBadge.ts b/src/client/components/ffaRating/FfaTierBadge.ts new file mode 100644 index 00000000000..efea4a5513c --- /dev/null +++ b/src/client/components/ffaRating/FfaTierBadge.ts @@ -0,0 +1,112 @@ +import { LitElement, TemplateResult, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../../Utils"; +import { + FFA_CALIBRATION_GAMES, + FfaTierBase, + ParsedFfaTier, + ffaTierLabel, + parseFfaTier, +} from "./FfaTier"; + +export type FfaBadgeSize = "sm" | "md" | "lg"; + +// Full literal class strings: Tailwind only generates classes it sees in source. +export const FFA_TIER_COLOR_CLASSES: Record = { + iron: "bg-stone-600 text-white", + bronze: "bg-amber-800 text-white", + silver: "bg-slate-400 text-slate-950", + gold: "bg-yellow-500 text-yellow-950", + platinum: "bg-teal-400 text-teal-950", + diamond: "bg-sky-400 text-sky-950", + master: "bg-purple-600 text-white", + grandmaster: "bg-red-600 text-white", +}; + +export const FFA_BADGE_SIZE_CLASSES: Record = { + sm: "text-[10px] px-1.5 py-0.5", + md: "text-xs px-2 py-0.5", + lg: "text-sm px-3 py-1", +}; + +/** + * A player's FFA rating tier, or a "Calibrating N/10" pill while they have no + * tier yet. The tier is always the API's string, never computed here. + * + * Placeholder look: a flat pill. Only renderTier (and renderCalibrating) draw + * the badge, so the final art can replace them without touching the rest. + */ +@customElement("ffa-tier-badge") +export class FfaTierBadge extends LitElement { + @property({ type: String }) tier?: string; + @property({ type: String }) size: FfaBadgeSize = "sm"; + @property({ type: Number }) calibrationGames?: number; + + createRenderRoot() { + return this; + } + + render() { + if (this.tier) { + const parsed = parseFfaTier(this.tier); + if (parsed === null) { + return html`${this.tier}`; + } + const label = ffaTierLabel(parsed); + return html`${this.renderTier(parsed, label)}`; + } + if (this.calibrationGames !== undefined) { + const n = Math.min( + FFA_CALIBRATION_GAMES, + Math.max(0, Math.round(this.calibrationGames)), + ); + const text = translateText("ffa_rating.calibrating", { n }); + return html`${this.renderCalibrating(n)}`; + } + return nothing; + } + + protected renderTier(tier: ParsedFfaTier, label: string): TemplateResult { + return html`${label}`; + } + + protected renderCalibrating(n: number): TemplateResult { + return html`${translateText("ffa_rating.calibrating", { n })}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ffa-tier-badge": FfaTierBadge; + } +} diff --git a/src/client/components/ffaRating/OfstatsCredit.ts b/src/client/components/ffaRating/OfstatsCredit.ts new file mode 100644 index 00000000000..2eea6e6a390 --- /dev/null +++ b/src/client/components/ffaRating/OfstatsCredit.ts @@ -0,0 +1,31 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { translateText } from "../../Utils"; + +export const OFSTATS_HOW_IT_WORKS_URL = + "https://ofstats.io/leaderboard/how-it-works"; + +/** "Rating by OFstats" link, shown wherever an FFA rating appears. */ +@customElement("ofstats-credit") +export class OfstatsCredit extends LitElement { + createRenderRoot() { + return this; + } + + render() { + return html`${translateText("ffa_rating.credit")}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ofstats-credit": OfstatsCredit; + } +} diff --git a/tests/client/FfaTierBadge.test.ts b/tests/client/FfaTierBadge.test.ts new file mode 100644 index 00000000000..fa91b14c6a5 --- /dev/null +++ b/tests/client/FfaTierBadge.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (k: string, p?: Record) => + p ? `${k}:${JSON.stringify(p)}` : k, +})); + +import { FFA_TIER_BASES } from "../../src/client/components/ffaRating/FfaTier"; +import { + FFA_TIER_COLOR_CLASSES, + type FfaTierBadge, +} from "../../src/client/components/ffaRating/FfaTierBadge"; +import { ALL_FFA_TIERS } from "../util/FfaTiers"; + +let badge: FfaTierBadge; +afterEach(() => badge?.remove()); + +async function render(props: Partial) { + badge = document.createElement("ffa-tier-badge") as FfaTierBadge; + Object.assign(badge, props); + document.body.appendChild(badge); + await badge.updateComplete; + return badge; +} + +describe("", () => { + it.each(ALL_FFA_TIERS)("draws %s with its base and label", async (tier) => { + await render({ tier }); + const el = badge.querySelector("[data-ffa-tier]")!; + const [name, division] = tier.split(" "); + const label = `ffa_rating.tier.${name.toLowerCase()}${division ? ` ${division}` : ""}`; + expect(el.dataset.ffaTier).toBe(name.toLowerCase()); + expect(el.title).toBe(label); + expect(badge.textContent).toContain(label); + }); + + it("gives each base tier its own colour", () => { + expect( + new Set(FFA_TIER_BASES.map((b) => FFA_TIER_COLOR_CLASSES[b])).size, + ).toBe(8); + }); + + it("shows the dashed Calibrating N/10 variant", async () => { + await render({ calibrationGames: 3 }); + const el = badge.querySelector("[data-ffa-calibrating]")!; + expect(el.textContent).toContain('ffa_rating.calibrating:{"n":3}'); + expect(badge.innerHTML).toContain("border-dashed"); + }); + + it("clamps and rounds the calibration count", async () => { + await render({ calibrationGames: 12 }); + expect(badge.textContent).toContain('ffa_rating.calibrating:{"n":10}'); + badge.calibrationGames = -2; + await badge.updateComplete; + expect(badge.textContent).toContain('ffa_rating.calibrating:{"n":0}'); + badge.calibrationGames = 2.6; + await badge.updateComplete; + expect(badge.textContent).toContain('ffa_rating.calibrating:{"n":3}'); + }); + + it("prefers the tier when both are set", async () => { + await render({ tier: "Gold I", calibrationGames: 3 }); + expect(badge.querySelector("[data-ffa-calibrating]")).toBeNull(); + expect(badge.querySelector("[data-ffa-tier]")).not.toBeNull(); + }); + + it("shows an unknown tier's text in a neutral pill", async () => { + await render({ tier: "Mythril II" }); + expect( + badge.querySelector("[data-ffa-tier]")!.dataset.ffaTier, + ).toBe("unknown"); + expect(badge.textContent).toContain("Mythril II"); + expect(badge.innerHTML).toContain("bg-white/10"); + }); + + it("renders nothing without a tier or a calibration count", async () => { + await render({}); + expect(badge.textContent!.trim()).toBe(""); + }); + + it("defaults to sm and sizes up", async () => { + await render({ tier: "Gold I" }); + expect(badge.innerHTML).toContain("text-[10px]"); + badge.size = "lg"; + await badge.updateComplete; + expect(badge.innerHTML).toContain("text-sm"); + }); +}); diff --git a/tests/client/OfstatsCredit.test.ts b/tests/client/OfstatsCredit.test.ts new file mode 100644 index 00000000000..63c0b66b957 --- /dev/null +++ b/tests/client/OfstatsCredit.test.ts @@ -0,0 +1,25 @@ +import type { LitElement } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (k: string, p?: Record) => + p ? `${k}:${JSON.stringify(p)}` : k, +})); + +import "../../src/client/components/ffaRating/OfstatsCredit"; + +let el: HTMLElement; +afterEach(() => el?.remove()); + +describe("", () => { + it("links to OFstats's how-it-works page in a new tab", async () => { + el = document.createElement("ofstats-credit"); + document.body.appendChild(el); + await (el as LitElement).updateComplete; + const a = el.querySelector("a")!; + expect(a.href).toBe("https://ofstats.io/leaderboard/how-it-works"); + expect(a.target).toBe("_blank"); + expect(a.rel).toBe("noopener noreferrer"); + expect(a.textContent).toContain("ffa_rating.credit"); + }); +}); From c3a91f8b017d90bc6422c523b0b068fafa8b022f Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:10:42 +0100 Subject: [PATCH 04/14] feat(client): poll GET /users/@me/ffa_rating/:gameId Extract pollUntilSettled from pollGameXp so the FFA rating poll shares it. Co-Authored-By: Claude Opus 5.5 --- src/client/FfaRatingApi.ts | 83 ++++++++++++++++++ src/client/ProgressionApi.ts | 55 ++++++++---- tests/client/FfaRatingApi.test.ts | 141 ++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 src/client/FfaRatingApi.ts create mode 100644 tests/client/FfaRatingApi.test.ts diff --git a/src/client/FfaRatingApi.ts b/src/client/FfaRatingApi.ts new file mode 100644 index 00000000000..3658aa13136 --- /dev/null +++ b/src/client/FfaRatingApi.ts @@ -0,0 +1,83 @@ +import { + type FfaGameRatingResponse, + FfaGameRatingResponseSchema, +} from "@openfront/shared/ApiSchemas"; +// ApiBase rather than Api: this module is imported by in-game UI (WinModal) +// whose tests stub Api wholesale. +import { getApiBase } from "./ApiBase"; +import { getAuthHeader } from "./Auth"; +import { + type PollOptions, + pollUntilSettled, + requestSignal, +} from "./ProgressionApi"; + +// FFA rating. Like progression, every helper here fails closed: the rating is +// extra information, so any error hides the UI rather than surfacing a failure. + +export type FfaGameRatingFetchResult = + | { status: "ok"; data: FfaGameRatingResponse } + // 404 (not rated yet, or the rating is off) or a transient failure — worth + // asking again while the caller is still polling. `notFound` marks the 404; + // `rateLimited` marks a 429. + | { status: "pending"; notFound?: true; rateLimited?: true } + // A conclusion: signed out, or a body we cannot read. Stop asking. + | { status: "unavailable" }; + +// GET /users/@me/ffa_rating/:gameId — the caller's rating change in one game. +export async function fetchMyFfaGameRating( + gameId: string, + signal?: AbortSignal, +): Promise { + try { + const authorization = await getAuthHeader(); + if (authorization === "") return { status: "unavailable" }; + const res = await fetch( + `${getApiBase()}/users/@me/ffa_rating/${encodeURIComponent(gameId)}`, + { + headers: { Accept: "application/json", Authorization: authorization }, + signal: requestSignal(signal), + }, + ); + if (res.status === 404) return { status: "pending", notFound: true }; + if (res.status === 429) return { status: "pending", rateLimited: true }; + if (res.status === 401 || res.status === 403) { + return { status: "unavailable" }; + } + if (!res.ok) { + console.warn("fetchMyFfaGameRating: unexpected status", res.status); + return { status: "pending" }; + } + const parsed = FfaGameRatingResponseSchema.safeParse(await res.json()); + if (!parsed.success) { + console.warn("fetchMyFfaGameRating: Zod validation failed", parsed.error); + return { status: "unavailable" }; + } + return { status: "ok", data: parsed.data }; + } catch (err) { + // The caller gave up: not a failure worth a warning. + if (signal?.aborted) return { status: "pending" }; + console.warn("fetchMyFfaGameRating: request failed", err); + return { status: "pending" }; + } +} + +export interface PollFfaGameRatingOptions extends PollOptions { + // Injectable for tests. + fetcher?: typeof fetchMyFfaGameRating; +} + +/** + * Asks for a game's FFA rating until it is scored, the deadline passes, or the + * caller aborts. Resolves to the response, or null when there is nothing to + * show (timed out, signed out, unreadable, aborted). + */ +export function pollFfaGameRating( + gameId: string, + opts: PollFfaGameRatingOptions = {}, +): Promise { + return pollUntilSettled( + (signal) => (opts.fetcher ?? fetchMyFfaGameRating)(gameId, signal), + opts, + ); +} diff --git a/src/client/ProgressionApi.ts b/src/client/ProgressionApi.ts index 5532fe9e5ab..9cc43206191 100644 --- a/src/client/ProgressionApi.ts +++ b/src/client/ProgressionApi.ts @@ -30,7 +30,7 @@ export type GameXpFetchResult = // The request's own timeout, plus the caller's signal when there is one, so // aborting cancels a request already in flight rather than leaving it to run // to its timeout. Without AbortSignal.any (older browsers), just the timeout. -function requestSignal(signal: AbortSignal | undefined): AbortSignal { +export function requestSignal(signal: AbortSignal | undefined): AbortSignal { const timeout = AbortSignal.timeout(10_000); if (signal === undefined || typeof AbortSignal.any !== "function") { return timeout; @@ -76,33 +76,33 @@ export async function fetchMyGameXp( } } -export interface PollGameXpOptions { +export type PollResult = + | { status: "ok"; data: T } + | { status: "pending" } + | { status: "unavailable" }; + +export interface PollOptions { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal; - // Injectable for tests. - fetcher?: ( - gameId: string, - signal?: AbortSignal, - ) => Promise; } /** - * Asks for a game's XP until it is processed, the deadline passes, or the - * caller aborts. Resolves to the response, or null when there is nothing to - * show (timed out, signed out, unreadable, aborted) — never a zero. + * Asks until the answer settles, the deadline passes, or the caller aborts. + * Resolves to the data, or null when there is nothing to show (timed out, + * unavailable, aborted). Shared by every "ask until the API has scored it" + * poll. */ -export async function pollGameXp( - gameId: string, - opts: PollGameXpOptions = {}, -): Promise { +export async function pollUntilSettled( + ask: (signal?: AbortSignal) => Promise>, + opts: PollOptions = {}, +): Promise { const intervalMs = opts.intervalMs ?? 3_000; const timeoutMs = opts.timeoutMs ?? 60_000; - const fetcher = opts.fetcher ?? fetchMyGameXp; const deadline = Date.now() + timeoutMs; for (;;) { if (opts.signal?.aborted) return null; - const result = await fetcher(gameId, opts.signal); + const result = await ask(opts.signal); if (opts.signal?.aborted) return null; if (result.status === "ok") return result.data; if (result.status === "unavailable") return null; @@ -124,6 +124,29 @@ export async function pollGameXp( } } +export interface PollGameXpOptions extends PollOptions { + // Injectable for tests. + fetcher?: ( + gameId: string, + signal?: AbortSignal, + ) => Promise; +} + +/** + * Asks for a game's XP until it is processed, the deadline passes, or the + * caller aborts. Resolves to the response, or null when there is nothing to + * show (timed out, signed out, unreadable, aborted) — never a zero. + */ +export function pollGameXp( + gameId: string, + opts: PollGameXpOptions = {}, +): Promise { + return pollUntilSettled( + (signal) => (opts.fetcher ?? fetchMyGameXp)(gameId, signal), + opts, + ); +} + // GET /public/player/:publicId/progress — another player's level. False on a // 404 (unknown player, progression off) and every other failure. export async function fetchPublicPlayerProgress( diff --git a/tests/client/FfaRatingApi.test.ts b/tests/client/FfaRatingApi.test.ts new file mode 100644 index 00000000000..93f8f8561cf --- /dev/null +++ b/tests/client/FfaRatingApi.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: vi.fn(async () => "Bearer test-token"), +})); + +vi.mock("../../src/client/ApiBase", () => ({ + getApiBase: () => "https://api.test", +})); + +import type { FfaGameRatingResponse } from "@openfront/shared/ApiSchemas"; +import { getAuthHeader } from "../../src/client/Auth"; +import { + fetchMyFfaGameRating, + pollFfaGameRating, +} from "../../src/client/FfaRatingApi"; + +const calibrating: FfaGameRatingResponse = { + gameId: "g1", + calibrating: true, + calibrationGames: 4, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchMyFfaGameRating", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function answer(response: () => Response) { + vi.stubGlobal( + "fetch", + vi.fn(async () => response()), + ); + } + + it("asks for the caller's seat in that game", async () => { + answer(() => new Response("", { status: 404 })); + await fetchMyFfaGameRating("game/1"); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + expect(url).toBe("https://api.test/users/@me/ffa_rating/game%2F1"); + expect((init!.headers as Record).Authorization).toBe( + "Bearer test-token", + ); + }); + + it.each([ + [404, { status: "pending", notFound: true }], + [429, { status: "pending", rateLimited: true }], + [401, { status: "unavailable" }], + [403, { status: "unavailable" }], + [500, { status: "pending" }], + ])("maps %i to %j", async (status, expected) => { + answer(() => new Response("", { status })); + expect(await fetchMyFfaGameRating("g1")).toEqual(expected); + }); + + it("is unavailable when signed out, without a request", async () => { + vi.mocked(getAuthHeader).mockResolvedValueOnce(""); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + expect(await fetchMyFfaGameRating("g1")).toEqual({ + status: "unavailable", + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("is unavailable for a body that fails the schema", async () => { + answer(() => jsonResponse({ gameId: 1 })); + expect(await fetchMyFfaGameRating("g1")).toEqual({ + status: "unavailable", + }); + }); + + it("returns a calibrating answer as ok", async () => { + answer(() => + jsonResponse({ gameId: "g1", calibrating: true, calibrationGames: 4 }), + ); + expect(await fetchMyFfaGameRating("g1")).toEqual({ + status: "ok", + data: calibrating, + }); + }); + + it("is pending when the request throws", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + expect(await fetchMyFfaGameRating("g1")).toEqual({ status: "pending" }); + }); +}); + +describe("pollFfaGameRating", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls past 404s until the seat is rated", async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockResolvedValueOnce({ status: "pending", notFound: true }) + .mockResolvedValueOnce({ status: "ok", data: calibrating }); + const result = pollFfaGameRating("g1", { intervalMs: 100, fetcher }); + await vi.advanceTimersByTimeAsync(500); + await expect(result).resolves.toEqual(calibrating); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("gives up with null at the deadline", async () => { + vi.useFakeTimers(); + const fetcher = vi.fn().mockResolvedValue({ status: "pending" }); + const result = pollFfaGameRating("g1", { + intervalMs: 100, + timeoutMs: 1_000, + fetcher, + }); + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toBeNull(); + }); + + it("stops at once on unavailable", async () => { + const fetcher = vi.fn().mockResolvedValue({ status: "unavailable" }); + await expect(pollFfaGameRating("g1", { fetcher })).resolves.toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); From bacf7aff6d320b383278d3c29014ec4bb9eeafc1 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:14:20 +0100 Subject: [PATCH 05/14] feat(client): win-screen FFA rating panel Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 9 +- .../components/ffaRating/FfaRatingPanel.ts | 301 ++++++++++++++++++ tests/client/FfaRatingPanel.test.ts | 241 ++++++++++++++ 3 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 src/client/components/ffaRating/FfaRatingPanel.ts create mode 100644 tests/client/FfaRatingPanel.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 4ecfe135465..2066c327537 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -930,7 +930,12 @@ }, "ffa_rating": { "calibrating": "Calibrating {n}/10", + "calibration_complete": "Calibration complete", "credit": "Rating by OFstats", + "expected": "Expected win: {pct}%", + "pending": "Calculating rating…", + "placement": "Placement", + "result": "Result", "tier": { "bronze": "Bronze", "diamond": "Diamond", @@ -940,7 +945,9 @@ "master": "Master", "platinum": "Platinum", "silver": "Silver" - } + }, + "tier_up": "Promoted to {tier}", + "title": "FFA Rating" }, "free_play": { "account": "Your account, stats, friends and cosmetics", diff --git a/src/client/components/ffaRating/FfaRatingPanel.ts b/src/client/components/ffaRating/FfaRatingPanel.ts new file mode 100644 index 00000000000..9b58b1f6704 --- /dev/null +++ b/src/client/components/ffaRating/FfaRatingPanel.ts @@ -0,0 +1,301 @@ +import type { FfaGameRatingResponse } from "@openfront/shared/ApiSchemas"; +import { LitElement, TemplateResult, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { pollFfaGameRating } from "../../FfaRatingApi"; +import { translateText } from "../../Utils"; +import { + ffaTierLabelOf, + ffaTierRank, + formatExpectedPct, + formatRatingChange, + formatRatingWithUncertainty, +} from "./FfaTier"; +import "./FfaTierBadge"; +import "./OfstatsCredit"; + +/** The smallest lobby (humans) whose FFA games are rated. */ +export const FFA_RATING_MIN_HUMANS = 10; + +export type FfaRatingPanelView = + | { kind: "hidden" } + | { kind: "pending" } + | { kind: "calibrating"; calibrationGames: number } + | { + kind: "revealed"; + ratingAfter: number; + uncertaintyAfter: number; + tierAfter: string; + } + | { + kind: "result"; + result: Required>; + }; + +/** What the panel shows for a polled response (null: nothing to show). */ +export function ffaRatingPanelView( + data: FfaGameRatingResponse | null, +): FfaRatingPanelView { + if (data === null) return { kind: "hidden" }; + if (data.calibrating) { + return { kind: "calibrating", calibrationGames: data.calibrationGames }; + } + const { + ratingBefore, + ratingAfter, + uncertaintyAfter, + tierBefore, + tierAfter, + expected, + deltaResult, + deltaPlace, + } = data; + // The game that completes calibration: the earlier rating was never shown, + // so there is no change to report. + if ( + data.revealed === true && + ratingAfter !== undefined && + uncertaintyAfter !== undefined && + tierAfter !== undefined + ) { + return { kind: "revealed", ratingAfter, uncertaintyAfter, tierAfter }; + } + if ( + ratingBefore === undefined || + ratingAfter === undefined || + uncertaintyAfter === undefined || + tierBefore === undefined || + tierAfter === undefined || + expected === undefined || + deltaResult === undefined || + deltaPlace === undefined + ) { + return { kind: "hidden" }; + } + return { + kind: "result", + result: { + gameId: data.gameId, + calibrating: data.calibrating, + calibrationGames: data.calibrationGames, + ratingBefore, + ratingAfter, + uncertaintyAfter, + tierBefore, + tierAfter, + expected, + deltaResult, + deltaPlace, + }, + }; +} + +const STYLE_ID = "ffa-rating-panel-styles"; + +const FFA_RATING_PANEL_CSS = /* css */ ` +@keyframes ffa-tier-up-in { + from { opacity: 0; transform: scale(0.6); } + to { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: no-preference) { + .ffa-tier-up { + animation: ffa-tier-up-in 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) 1 both; + } +} +`; + +// Once per document, in , like ProfileCard's styles. +function ensureFfaRatingPanelStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = FFA_RATING_PANEL_CSS; + document.head.appendChild(style); +} + +function changeColor(n: number): string { + const r = Math.round(n); + return r > 0 ? "text-green-400" : r < 0 ? "text-red-400" : "text-white/50"; +} + +/** + * The win screen's FFA rating: the new rating and tier, the change split into + * result and placement, the pre-game win chance, and a promotion line. Asks + * the API once for a lobby big enough to be rated; shows nothing at all when + * there is no rating to show. + */ +@customElement("ffa-rating-panel") +export class FfaRatingPanel extends LitElement { + @property({ type: String }) gameId = ""; + @property({ type: Number }) lobbyHumans = 0; + // Replaceable for tests and screenshots. + @property({ attribute: false }) poll: ( + gameId: string, + signal: AbortSignal, + ) => Promise = (id, signal) => + pollFfaGameRating(id, { signal }); + + private view: FfaRatingPanelView = { kind: "hidden" }; + private polledGameId: string | null = null; + private abort: AbortController | null = null; + + createRenderRoot() { + return this; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.abort?.abort(); + this.abort = null; + // A re-attached panel asks again. + this.polledGameId = null; + } + + protected updated() { + if ( + !this.isConnected || + this.gameId === "" || + this.lobbyHumans < FFA_RATING_MIN_HUMANS || + this.polledGameId === this.gameId + ) { + return; + } + ensureFfaRatingPanelStyles(); + this.abort?.abort(); + const abort = new AbortController(); + this.abort = abort; + this.polledGameId = this.gameId; + this.view = { kind: "pending" }; + this.requestUpdate(); + void this.poll(this.gameId, abort.signal).then( + (data) => this.settle(abort, ffaRatingPanelView(data)), + () => this.settle(abort, { kind: "hidden" }), + ); + } + + private settle(abort: AbortController, view: FfaRatingPanelView) { + if (abort.signal.aborted) return; + this.view = view; + this.requestUpdate(); + } + + render() { + const view = this.view; + switch (view.kind) { + case "hidden": + return nothing; + case "pending": + return this.section( + "pending", + html`

+ ${translateText("ffa_rating.pending")} +

`, + ); + case "calibrating": + return this.section( + "calibrating", + html` + `, + ); + case "revealed": + return this.section( + "revealed", + html`

+ ${translateText("ffa_rating.calibration_complete")} +

+
+ + ${formatRatingWithUncertainty( + view.ratingAfter, + view.uncertaintyAfter, + )} +
+ `, + ); + case "result": + return this.section("result", this.renderResult(view.result)); + } + } + + private section(kind: string, body: TemplateResult) { + return html`
+

+ ${translateText("ffa_rating.title")} +

+ ${body} +
`; + } + + private renderResult(r: Required>) { + const change = r.ratingAfter - r.ratingBefore; + const rankBefore = ffaTierRank(r.tierBefore); + const rankAfter = ffaTierRank(r.tierAfter); + const promoted = + rankBefore !== null && rankAfter !== null && rankAfter > rankBefore; + return html`
+ + ${formatRatingWithUncertainty( + r.ratingAfter, + r.uncertaintyAfter, + )} + ${formatRatingChange(change)} +
+ ${translateText("ffa_rating.expected", { + pct: formatExpectedPct(r.expected), + })} +
+
+ ${translateText("ffa_rating.result")} + ${formatRatingChange(r.deltaResult)} +
+
+ ${translateText("ffa_rating.placement")} + ${formatRatingChange(r.deltaPlace)} +
+
+ ${promoted + ? html`

+ ${translateText("ffa_rating.tier_up", { + tier: ffaTierLabelOf(r.tierAfter), + })} +

` + : nothing} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ffa-rating-panel": FfaRatingPanel; + } +} diff --git a/tests/client/FfaRatingPanel.test.ts b/tests/client/FfaRatingPanel.test.ts new file mode 100644 index 00000000000..71bbc2060d0 --- /dev/null +++ b/tests/client/FfaRatingPanel.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (k: string, p?: Record) => + p ? `${k}:${JSON.stringify(p)}` : k, +})); + +import type { FfaGameRatingResponse } from "@openfront/shared/ApiSchemas"; +import { + FFA_RATING_MIN_HUMANS, + type FfaRatingPanel, + ffaRatingPanelView, +} from "../../src/client/components/ffaRating/FfaRatingPanel"; +import type { FfaTierBadge } from "../../src/client/components/ffaRating/FfaTierBadge"; + +const rated: FfaGameRatingResponse = { + gameId: "g1", + calibrating: false, + calibrationGames: 10, + ratingBefore: 1600.4, + ratingAfter: 1612.6, + uncertaintyAfter: 84.7, + tierBefore: "Gold I", + tierAfter: "Platinum III", + expected: 0.0623, + deltaResult: 9.1, + deltaPlace: 3.1, +}; + +let panel: FfaRatingPanel; +afterEach(() => panel?.remove()); + +async function render(poll: FfaRatingPanel["poll"], lobbyHumans = 24) { + panel = document.createElement("ffa-rating-panel") as FfaRatingPanel; + panel.poll = poll; + panel.gameId = "g1"; + panel.lobbyHumans = lobbyHumans; + document.body.appendChild(panel); + await panel.updateComplete; + return panel; +} + +const text = (sel: string) => panel.querySelector(sel)?.textContent ?? null; + +describe("ffaRatingPanelView", () => { + it("maps a missing response to hidden", () => { + expect(ffaRatingPanelView(null)).toEqual({ kind: "hidden" }); + }); + + it("maps a calibrating response to calibrating", () => { + expect( + ffaRatingPanelView({ + gameId: "g1", + calibrating: true, + calibrationGames: 4, + }), + ).toEqual({ kind: "calibrating", calibrationGames: 4 }); + }); + + it("maps the calibration-completing game to revealed", () => { + expect( + ffaRatingPanelView({ + gameId: "g1", + calibrating: false, + revealed: true, + calibrationGames: 10, + ratingAfter: 1712.4, + uncertaintyAfter: 120.2, + tierAfter: "Gold II", + }), + ).toEqual({ + kind: "revealed", + ratingAfter: 1712.4, + uncertaintyAfter: 120.2, + tierAfter: "Gold II", + }); + }); + + it("hides a revealed response missing a field and has no result fallback", () => { + expect( + ffaRatingPanelView({ + gameId: "g1", + calibrating: false, + revealed: true, + calibrationGames: 10, + ratingAfter: 1712.4, + }), + ).toEqual({ kind: "hidden" }); + }); + + it("maps a complete rated response to result", () => { + const view = ffaRatingPanelView(rated); + expect(view.kind).toBe("result"); + }); + + it("hides a rated response with a missing field", () => { + const partial: FfaGameRatingResponse = { ...rated, deltaPlace: undefined }; + expect(ffaRatingPanelView(partial)).toEqual({ kind: "hidden" }); + }); +}); + +describe("", () => { + it("says the rating is being calculated while the seat is unrated", async () => { + await render(() => new Promise(() => {})); + expect(panel.textContent).toContain("ffa_rating.pending"); + }); + + it("shows expected win, the split change and the new rating", async () => { + await render(async () => rated); + await vi.waitFor(() => + expect( + panel.querySelector("[data-ffa-rating-panel='result']"), + ).not.toBeNull(), + ); + expect(text("[data-ffa-rating-after]")).toContain("1613 ± 85"); + expect(text("[data-ffa-rating-change]")).toContain("+12"); + expect(text("[data-ffa-rating-expected]")).toContain( + 'ffa_rating.expected:{"pct":"6.2"}', + ); + expect(text("[data-ffa-rating-result]")).toContain("+9"); + expect(text("[data-ffa-rating-placement]")).toContain("+3"); + expect(panel.querySelector("ffa-tier-badge")!.tier).toBe( + "Platinum III", + ); + expect(panel.querySelector("ofstats-credit")).not.toBeNull(); + }); + + it("colours the change by sign", async () => { + await render(async () => rated); + await vi.waitFor(() => + expect(panel.querySelector("[data-ffa-rating-change]")).not.toBeNull(), + ); + expect( + panel.querySelector("[data-ffa-rating-change]")!.className, + ).toContain("text-green-400"); + panel.remove(); + await render(async () => ({ + ...rated, + ratingAfter: 1590, + tierAfter: "Gold I", + })); + await vi.waitFor(() => + expect(panel.querySelector("[data-ffa-rating-change]")).not.toBeNull(), + ); + expect( + panel.querySelector("[data-ffa-rating-change]")!.className, + ).toContain("text-red-400"); + }); + + it("announces a promotion", async () => { + await render(async () => rated); + await vi.waitFor(() => + expect(text("[data-ffa-tier-up]")).toContain( + 'ffa_rating.tier_up:{"tier":"ffa_rating.tier.platinum III"}', + ), + ); + }); + + it.each([ + ["Platinum III", "Platinum III"], + ["Platinum III", "Gold I"], + ])("has no promotion line from %s to %s", async (tierBefore, tierAfter) => { + await render(async () => ({ ...rated, tierBefore, tierAfter })); + await vi.waitFor(() => + expect(panel.querySelector("[data-ffa-rating-after]")).not.toBeNull(), + ); + expect(panel.querySelector("[data-ffa-tier-up]")).toBeNull(); + }); + + it("reveals the rating, with no change, on the game that completes calibration", async () => { + await render(async () => ({ + gameId: "g1", + calibrating: false, + revealed: true, + calibrationGames: 10, + ratingAfter: 1712.4, + uncertaintyAfter: 120.2, + tierAfter: "Gold II", + })); + await vi.waitFor(() => + expect( + panel.querySelector("[data-ffa-rating-panel='revealed']"), + ).not.toBeNull(), + ); + expect(panel.textContent).toContain("ffa_rating.calibration_complete"); + expect(text("[data-ffa-rating-after]")).toContain("1712 ± 120"); + expect(panel.querySelector("ffa-tier-badge")!.tier).toBe( + "Gold II", + ); + expect(panel.querySelector("[data-ffa-rating-change]")).toBeNull(); + expect(panel.querySelector("[data-ffa-rating-expected]")).toBeNull(); + expect(panel.querySelector("ofstats-credit")).not.toBeNull(); + }); + + it("shows Calibrating N/10 and no numbers while calibrating", async () => { + await render(async () => ({ + gameId: "g1", + calibrating: true, + calibrationGames: 4, + })); + await vi.waitFor(() => + expect( + panel.querySelector("ffa-tier-badge")?.calibrationGames, + ).toBe(4), + ); + expect(panel.querySelector("[data-ffa-rating-after]")).toBeNull(); + expect(panel.querySelector("ofstats-credit")).not.toBeNull(); + }); + + it("renders nothing and asks nothing for a lobby under 10 humans", async () => { + const poll = vi.fn(); + await render(poll, FFA_RATING_MIN_HUMANS - 1); + expect(poll).not.toHaveBeenCalled(); + expect(panel.textContent!.trim()).toBe(""); + }); + + it("renders nothing once the poll gives up", async () => { + await render(async () => null); + await vi.waitFor(() => expect(panel.textContent!.trim()).toBe("")); + }); + + it("asks once per game", async () => { + const poll = vi.fn( + () => new Promise(() => {}), + ); + await render(poll); + panel.lobbyHumans = 25; + await panel.updateComplete; + expect(poll).toHaveBeenCalledTimes(1); + }); + + it("stops asking once removed", async () => { + let signal!: AbortSignal; + await render((_id, s) => { + signal = s; + return new Promise(() => {}); + }); + panel.remove(); + expect(signal.aborted).toBe(true); + }); +}); From 65194d6100f08c0bad29f5a05ebfb13c32f76850 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:17:25 +0100 Subject: [PATCH 06/14] feat(client): FFA rating panel on the win screen Co-Authored-By: Claude Opus 5.5 --- .../ffaRating/FfaRatingEligibility.ts | 53 +++++++++++ src/client/hud/layers/WinModal.ts | 53 ++++++++++- tests/client/FfaRatingEligibility.test.ts | 81 +++++++++++++++++ tests/client/graphics/layers/WinModal.test.ts | 89 ++++++++++++++++++- 4 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/client/components/ffaRating/FfaRatingEligibility.ts create mode 100644 tests/client/FfaRatingEligibility.test.ts diff --git a/src/client/components/ffaRating/FfaRatingEligibility.ts b/src/client/components/ffaRating/FfaRatingEligibility.ts new file mode 100644 index 00000000000..06940a867c8 --- /dev/null +++ b/src/client/components/ffaRating/FfaRatingEligibility.ts @@ -0,0 +1,53 @@ +import { + GameMode, + GameType, + PlayerType, +} from "@openfront/engine-api/game/GameTypes"; +import type { UserMeResponse } from "@openfront/shared/ApiSchemas"; +import type { GameView } from "../../view"; + +// Pure and light (types and engine-api enums only): the win screen imports it +// statically and loads the panel itself only once this says there is one. + +/** A game whose rating the win screen may show. */ +export interface FfaRatingGame { + gameId: string; + /** Humans who spawned: the panel applies the rated-lobby floor itself. */ + lobbyHumans: number; +} + +/** + * The game as an FFA rating candidate: a public, unranked FFA game that the + * viewer played (not a replay, not as a spectator). null for anything else. + */ +export function ffaRatingGameOf( + game: Pick, +): FfaRatingGame | null { + const config = game.config(); + const { gameType, gameMode, rankedType } = config.gameConfig(); + if ( + gameType !== GameType.Public || + gameMode !== GameMode.FFA || + rankedType !== undefined || + config.isReplay() || + game.myPlayer() === null + ) { + return null; + } + const lobbyHumans = game + .players() + .filter((p) => p.type() === PlayerType.Human && p.hasSpawned()).length; + return { gameId: game.gameID(), lobbyHumans }; +} + +/** + * Whether the viewer can have a rating to show. Untrusted seats are never + * rated, and the API sends `tier` or `calibrationGames` only once the rating + * is live (before that, `@me` carries the frozen Elo alone), so without both + * the panel would poll a 404 for its whole timeout. + */ +export function viewerHasFfaRating(me: UserMeResponse): boolean { + if (me.player.trustTier !== "trusted") return false; + const ffa = me.player.leaderboard?.ffa; + return ffa?.tier !== undefined || ffa?.calibrationGames !== undefined; +} diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 7a290973da5..a4084555b93 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -4,7 +4,7 @@ import type { AllPlayersStats, Winner } from "@openfront/engine-api/Schemas"; import type { GameXpEligible, Progress } from "@openfront/shared/ApiSchemas"; import { Pattern } from "@openfront/shared/CosmeticSchemas"; import { EventBus } from "@openfront/shared/EventBus"; -import { html, LitElement, TemplateResult } from "lit"; +import { html, LitElement, nothing, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { DESKTOP_TUTORIAL_VIDEO_URL, @@ -18,6 +18,11 @@ import { import { fetchUserMeUncached, getUserMe, invalidateUserMe } from "../../Api"; import "../../components/CosmeticCard"; import { cosmeticSelectionLabel } from "../../components/CosmeticPresentation"; +import { + ffaRatingGameOf, + type FfaRatingGame, + viewerHasFfaRating, +} from "../../components/ffaRating/FfaRatingEligibility"; import type { GameXpPanelState } from "../../components/GameXpPanel"; import type { LegendCeremony } from "../../components/LegendCeremony"; import "../../components/ProfileShare"; @@ -91,6 +96,12 @@ function loadGameXpPanel(): void { ); } +// The rating panel is only worth its bytes after a rated FFA game: fetched +// when the win screen finds the viewer has a rating to show. +function loadFfaRatingPanel(): Promise { + return import("../../components/ffaRating/FfaRatingPanel"); +} + @customElement("win-modal") export class WinModal extends LitElement implements Controller { public game: GameView; @@ -114,6 +125,11 @@ export class WinModal extends LitElement implements Controller { // no progression, a spectator or a replay never see the section at all. @state() private xpView: GameXpPanelState = { kind: "hidden" }; + // The FFA rating this game may have changed; null shows no panel. + @state() + private ffaRating: FfaRatingGame | null = null; + // Decided once per game. + private ffaRatingChecked = false; // Polling starts once, at the end of the game. private xpPolling = false; private xpAbort: AbortController | null = null; @@ -182,6 +198,13 @@ export class WinModal extends LitElement implements Controller { .gameType=${this.game?.config().gameConfig().gameType ?? null} @xp-legend=${this.onXpLegend} > + ${this.ffaRating === null + ? nothing + : html``} ${this.innerHtml()}
this.handleScroll()} > - + @@ -590,7 +729,11 @@ export class LeaderboardPlayerList extends LitElement { ${translateText("leaderboard_modal.player")}
- ${translateText("leaderboard_modal.elo")} + ${translateText( + ratingLive + ? "ffa_rating.rating" + : "leaderboard_modal.elo", + )} ${translateText("leaderboard_modal.games")} @@ -609,6 +752,11 @@ export class LeaderboardPlayerList extends LitElement {
${this.renderPlayerFooter()}
+ ${ratingLive + ? html`` + : nothing}
`; diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index ff2108b9d91..aeb50714e6e 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -17,6 +17,8 @@ vi.mock("../../src/client/Utils", () => ({ "leaderboard_modal.title": "Leaderboard", "leaderboard_modal.ranked_tab": "Ranked", "leaderboard_modal.ranked_2v2_tab": "2v2 Ranked", + "leaderboard_modal.ffa_rating_tab": "FFA Rating", + "ffa_rating.rating": "Rating", "leaderboard_modal.ranked_no_stats": "No ranked games on this ladder", "leaderboard_modal.clans_tab": "Clans", "leaderboard_modal.refresh_time": "Refreshed every 1 hour", @@ -135,6 +137,7 @@ beforeEach(() => { import { RankedType } from "@openfront/engine-api/game/GameTypes"; import { RankedLeaderboardResponseSchema } from "@openfront/shared/ApiSchemas"; import "../../src/client/components/baseComponents/Modal"; +import type { FfaTierBadge } from "../../src/client/components/ffaRating/FfaTierBadge"; import { LeaderboardModal } from "../../src/client/LeaderboardModal"; describe("LeaderboardModal", () => { @@ -679,6 +682,175 @@ describe("LeaderboardModal", () => { }); }); + describe("FFA rating tab", () => { + const entry = (rank: number, publicId: string, elo: number) => ({ + rank, + elo, + peakElo: elo, + wins: 1, + losses: 1, + total: 2, + public_id: publicId, + accountUsername: publicId, + }); + const rated = (rank: number, id: string, elo: number, tier: string) => ({ + ...entry(rank, id, elo), + uncertainty: 61.2, + tier, + }); + const ratingHeader = () => + modal.querySelectorAll("leaderboard-player-list thead th")[2].textContent; + const fetchMock = () => global.fetch as ReturnType; + const meOnce = async (me: unknown) => { + const { getUserMe } = await import("../../src/client/Api"); + vi.mocked(getUserMe).mockResolvedValueOnce(me as never); + }; + + it("labels the FFA tab FFA Rating", () => { + const tabs = ( + modal as unknown as { + modalConfig(): { tabs: { key: string; label: string }[] }; + } + ).modalConfig().tabs; + expect(tabs.find((t) => t.key === "playersFfa")!.label).toBe( + "FFA Rating", + ); + }); + + it("shows a tier badge and rating ± on the FFA tab only", async () => { + fetchMock().mockResolvedValueOnce( + jsonRes({ + "1v1": [entry(1, "p1", 1200)], + "2v2": [], + ffa: [rated(1, "p4", 2012.6, "Diamond II")], + }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await getPlayerList()!.updateComplete; + expect(modal.querySelectorAll("ffa-tier-badge")).toHaveLength(0); // 1v1 tab + expect(modal.querySelector("ofstats-credit")).toBeNull(); + expect(ratingHeader()).toContain("Elo"); + + await showLadder("playersFfa"); + const cell = modal.querySelector("[data-ffa-rating-cell]")!; + expect(cell.querySelector("ffa-tier-badge")!.tier).toBe( + "Diamond II", + ); + expect(cell.textContent).toContain("2013"); + expect(cell.textContent).toContain("± 61"); + expect(ratingHeader()).toContain("Rating"); + expect(modal.querySelector("ofstats-credit")).not.toBeNull(); + }); + + it("shows the frozen FFA ladder plainly", async () => { + fetchMock().mockResolvedValueOnce( + jsonRes({ "1v1": [], "2v2": [], ffa: [entry(1, "p4", 1300)] }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await showLadder("playersFfa"); + expect(modal.querySelector("ffa-tier-badge")).toBeNull(); + expect(modal.querySelector("[data-ffa-rating-cell]")).toBeNull(); + expect(modal.querySelector("ofstats-credit")).toBeNull(); + expect(ratingHeader()).toContain("Elo"); + const cols = modal.querySelectorAll( + "leaderboard-player-list col", + ); + expect(cols[2].style.width).toBe("6rem"); + }); + + it("widens the rating column once rows carry a tier", async () => { + fetchMock().mockResolvedValueOnce( + jsonRes({ + "1v1": [], + "2v2": [], + ffa: [rated(1, "p4", 2012.6, "Diamond II")], + }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await showLadder("playersFfa"); + const cols = modal.querySelectorAll( + "leaderboard-player-list col", + ); + expect(cols[2].style.width).toBe("12rem"); + }); + + it("pins a calibrating row for a signed-in player not on the board", async () => { + await meOnce({ + player: { + publicId: "me", + username: "me.1", + leaderboard: { ffa: { games: 4, wins: 1, calibrationGames: 4 } }, + }, + }); + fetchMock().mockResolvedValueOnce( + jsonRes({ + "1v1": [entry(1, "p1", 1200)], + "2v2": [], + ffa: [rated(1, "p4", 2012.6, "Diamond II")], + }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await getPlayerList()!.updateComplete; + expect(modal.querySelector("[data-ffa-calibrating-row]")).toBeNull(); // 1v1 tab + await showLadder("playersFfa"); + const row = modal.querySelector("tfoot [data-ffa-calibrating-row]")!; + expect( + row.querySelector("ffa-tier-badge")!.calibrationGames, + ).toBe(4); + const cells = row.querySelectorAll("td"); + expect(cells).toHaveLength(5); + expect(cells[0].textContent).toContain("—"); + expect(cells[1].textContent).toContain("Your ranking"); + expect(cells[1].querySelector("player-name")).not.toBeNull(); + expect(cells[3].textContent).toContain("4"); + expect(cells[4].textContent).toContain("25.0%"); + }); + + it("shows a dash win ratio for a calibrating player with no games", async () => { + await meOnce({ + player: { + publicId: "me", + leaderboard: { ffa: { calibrationGames: 0 } }, + }, + }); + fetchMock().mockResolvedValueOnce( + jsonRes({ + "1v1": [], + "2v2": [], + ffa: [rated(1, "p4", 2012.6, "Diamond II")], + }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await showLadder("playersFfa"); + const cells = modal.querySelectorAll( + "tfoot [data-ffa-calibrating-row] td", + ); + expect(cells[3].textContent).toContain("0"); + expect(cells[4].textContent).toContain("—"); + }); + + it("pins no calibrating row for a listed player", async () => { + await meOnce({ + player: { + publicId: "p4", + leaderboard: { + ffa: { elo: 2012.6, tier: "Diamond II", games: 40, wins: 9 }, + }, + }, + }); + fetchMock().mockResolvedValueOnce( + jsonRes({ + "1v1": [entry(1, "p1", 1200)], + "2v2": [], + ffa: [rated(1, "p4", 2012.6, "Diamond II")], + }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await showLadder("playersFfa"); + expect(modal.querySelector("[data-ffa-calibrating-row]")).toBeNull(); + }); + }); + describe("Level badges", () => { type Level = { level: number; prestige: number; legend: boolean }; const entry = (rank: number, publicId: string, level?: Level) => ({ From 8a9514d7a5956767f712c9a1f0c39db7078a3299 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:26:59 +0100 Subject: [PATCH 08/14] feat(client): FFA rating on the profile Co-Authored-By: Claude Opus 5.5 --- resources/lang/en.json | 1 + src/client/AccountModal.ts | 13 ++ src/client/PlayerProfileModal.ts | 17 ++- src/client/components/ProfileCard.ts | 10 ++ .../components/ffaRating/FfaRatingSummary.ts | 125 ++++++++++++++++ tests/client/AccountModalProgression.test.ts | 18 +++ tests/client/FfaRatingSummary.test.ts | 136 ++++++++++++++++++ tests/client/PlayerProfileModal.test.ts | 48 +++++++ tests/client/ProfileCard.test.ts | 35 +++++ 9 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 src/client/components/ffaRating/FfaRatingSummary.ts create mode 100644 tests/client/FfaRatingSummary.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 3b7435e26fa..76dc6f79ba6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -933,6 +933,7 @@ "calibration_complete": "Calibration complete", "credit": "Rating by OFstats", "expected": "Expected win: {pct}%", + "peak": "Peak {rating}", "pending": "Calculating rating…", "placement": "Placement", "rating": "Rating", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 89e366a282c..2b17409a01a 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -35,6 +35,8 @@ import "./components/CreatorCodePanel"; import type { CreatorChangedDetail } from "./components/CreatorCodePanel"; import "./components/CurrencyDisplay"; import "./components/Difficulties"; +import "./components/ffaRating/FfaRatingSummary"; +import { ffaSummaryFromMe } from "./components/ffaRating/FfaRatingSummary"; import "./components/FriendsList"; import type { PrestigeFlow } from "./components/PrestigeFlow"; import "./components/ProfileCard"; @@ -642,6 +644,16 @@ export class AccountModal extends BaseModal { variant: "full" | "compact", ): TemplateResult | typeof nothing { const player = this.userMeResponse?.player; + const ffaRating = ffaSummaryFromMe(player?.leaderboard?.ffa); + // No level to show (progression off): the full tab still shows the rating. + if (player?.publicId && !player.progress) { + return variant === "full" && ffaRating !== null + ? html`` + : nothing; + } if (!player?.publicId || !player.progress) return nothing; // With any XP still waiting to be scored, provisionally: it never offers // Prestige (only the confirmed level can). @@ -653,6 +665,7 @@ export class AccountModal extends BaseModal { .clanTag=${this.displayClanTag} .progress=${view.progress} .provisional=${view.provisional} + .ffaRating=${ffaRating} .openKey=${`account-${this.openCount}`} prestigeable @prestige-request=${this.handlePrestigeRequest} diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 9466b5a4b7c..910433e1486 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -15,6 +15,11 @@ import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/P import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; import "./components/clan/ClanCard"; +import "./components/ffaRating/FfaRatingSummary"; +import { + ffaSummaryFromProfile, + type FfaRatingSummaryData, +} from "./components/ffaRating/FfaRatingSummary"; import "./components/PlayerName"; import "./components/ProfileCard"; import "./components/ProfileProgression"; @@ -55,6 +60,7 @@ export class PlayerProfileModal extends BaseModal { @state() private statsTree: PlayerStatsTree | null = null; @state() private clans: NonNullable = []; @state() private displayClanTag: string | null = null; + @state() private ffaRating: FfaRatingSummaryData | null = null; // Level / XP, when progression is on and the player has any. @state() private progress: PublicProgress | null = null; @state() private loading = false; @@ -312,13 +318,19 @@ export class PlayerProfileModal extends BaseModal { const view = this.progressView(); return html` ${view === null - ? nothing + ? this.ffaRating === null + ? nothing + : html`` : html``} ${clan} ${name} ${this.renderLevelLine(progress)} ${this.renderProvisional()} ${this.renderXp(progress)} + ${this.ffaRating === null + ? nothing + : html``} ${this.canPrestige(progress) ? html`
["ffa"] +>; + +/** What the profile shows of a player's FFA rating. */ +export type FfaRatingSummaryData = + | { + kind: "rated"; + rating: number; + uncertainty: number; + tier: string; + peak?: number; + } + | { kind: "calibrating"; calibrationGames: number }; + +/** + * The signed-in player's rating from `@me`. Rated once the API sends the + * rating, its uncertainty and its tier; calibrating while it sends only the + * calibration count; null before the rating is live (`{ elo }` alone). + */ +export function ffaSummaryFromMe( + ffa: MeFfaEntry | undefined, +): FfaRatingSummaryData | null { + if (ffa === undefined) return null; + if ( + ffa.elo !== undefined && + ffa.uncertainty !== undefined && + ffa.tier !== undefined + ) { + return { + kind: "rated", + rating: ffa.elo, + uncertainty: ffa.uncertainty, + tier: ffa.tier, + ...(ffa.peak === undefined ? {} : { peak: ffa.peak }), + }; + } + if (ffa.calibrationGames !== undefined) { + return { kind: "calibrating", calibrationGames: ffa.calibrationGames }; + } + return null; +} + +/** Another player's rating from their public profile (null: unrated). */ +export function ffaSummaryFromProfile( + r: PublicFfaRating | null | undefined, +): FfaRatingSummaryData | null { + if (r === null || r === undefined) return null; + return { + kind: "rated", + rating: r.rating, + uncertainty: r.uncertainty, + tier: r.tier, + peak: r.peak, + }; +} + +/** + * A player's FFA rating on their profile: the tier badge (or the calibration + * count), the rating with its uncertainty, the peak, and the OFstats credit. + * Nothing at all without a rating. + */ +@customElement("ffa-rating-summary") +export class FfaRatingSummary extends LitElement { + @property({ attribute: false }) rating: FfaRatingSummaryData | null = null; + + createRenderRoot() { + return this; + } + + render() { + const rating = this.rating; + if (rating === null) return nothing; + return html`
+ ${translateText("ffa_rating.title")} + ${rating.kind === "rated" + ? html` + ${formatRatingWithUncertainty( + rating.rating, + rating.uncertainty, + )} + ${rating.peak === undefined + ? nothing + : html`${translateText("ffa_rating.peak", { + rating: formatRating(rating.peak), + })}`}` + : html``} + +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ffa-rating-summary": FfaRatingSummary; + } +} diff --git a/tests/client/AccountModalProgression.test.ts b/tests/client/AccountModalProgression.test.ts index 10793ed37d4..23f73cefda0 100644 --- a/tests/client/AccountModalProgression.test.ts +++ b/tests/client/AccountModalProgression.test.ts @@ -463,6 +463,24 @@ describe("AccountModal — Progression tab", () => { expect(fetchPlayerByIdMock).toHaveBeenCalledWith("me"); }); + it("puts the account's own FFA rating on the full card", async () => { + me = { + ...me, + player: { + ...me.player!, + leaderboard: { ffa: { games: 3, wins: 1, calibrationGames: 3 } }, + }, + } as UserMeResponse; + modal.open(); + await settle(() => { + expect( + modal.querySelector( + "profile-card", + )?.ffaRating, + ).toEqual({ kind: "calibrating", calibrationGames: 3 }); + }); + }); + it("opens on the tab from a link", async () => { history.replaceState(null, "", "/#modal=account&tab=progression"); expect(modalRouter.routeFromHash()).toBe(true); diff --git a/tests/client/FfaRatingSummary.test.ts b/tests/client/FfaRatingSummary.test.ts new file mode 100644 index 00000000000..4d9ec48cdf2 --- /dev/null +++ b/tests/client/FfaRatingSummary.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: (k: string, p?: Record) => + p ? `${k}:${JSON.stringify(p)}` : k, +})); + +import { + type FfaRatingSummary, + type FfaRatingSummaryData, + ffaSummaryFromMe, + ffaSummaryFromProfile, +} from "../../src/client/components/ffaRating/FfaRatingSummary"; +import type { FfaTierBadge } from "../../src/client/components/ffaRating/FfaTierBadge"; + +let el: FfaRatingSummary; +afterEach(() => el?.remove()); + +async function render(rating: FfaRatingSummaryData | null) { + el = document.createElement("ffa-rating-summary") as FfaRatingSummary; + el.rating = rating; + document.body.appendChild(el); + await el.updateComplete; + await el.querySelector("ffa-tier-badge")?.updateComplete; + return el; +} + +describe("ffaSummaryFromMe / ffaSummaryFromProfile", () => { + it.each([ + [undefined, null], + [{ elo: 1234 }, null], + [ + { games: 3, wins: 1, calibrationGames: 3 }, + { kind: "calibrating", calibrationGames: 3 }, + ], + [ + { + elo: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + games: 40, + wins: 5, + peak: 1700, + }, + { + kind: "rated", + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + peak: 1700, + }, + ], + ])("reads @me's %j as %j", (ffa, expected) => + expect(ffaSummaryFromMe(ffa as never)).toEqual(expected), + ); + + it("reads another player's profile rating", () => { + expect(ffaSummaryFromProfile(null)).toBeNull(); + expect(ffaSummaryFromProfile(undefined)).toBeNull(); + expect( + ffaSummaryFromProfile({ + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + games: 40, + wins: 5, + peak: 1700, + }), + ).toEqual({ + kind: "rated", + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + peak: 1700, + }); + }); +}); + +describe("", () => { + it("shows the badge, rating ±, peak and credit", async () => { + await render({ + kind: "rated", + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + peak: 1700, + }); + expect( + el + .querySelector("[data-ffa-rating-summary]")! + .getAttribute("data-ffa-rating-summary"), + ).toBe("rated"); + expect(el.textContent).toContain("ffa_rating.title"); + expect(el.querySelector("ffa-tier-badge")!.tier).toBe( + "Gold I", + ); + expect(el.querySelector("[data-ffa-rating-value]")!.textContent).toContain( + "1613 ± 85", + ); + expect(el.querySelector("[data-ffa-rating-peak]")!.textContent).toContain( + 'ffa_rating.peak:{"rating":"1700"}', + ); + expect(el.querySelector("ofstats-credit")).not.toBeNull(); + }); + + it("leaves the peak out when there is none", async () => { + await render({ + kind: "rated", + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + }); + expect(el.querySelector("[data-ffa-rating-value]")).not.toBeNull(); + expect(el.querySelector("[data-ffa-rating-peak]")).toBeNull(); + }); + + it("shows Calibrating for a calibrating player", async () => { + await render({ kind: "calibrating", calibrationGames: 3 }); + expect( + el + .querySelector("[data-ffa-rating-summary]")! + .getAttribute("data-ffa-rating-summary"), + ).toBe("calibrating"); + expect( + el.querySelector("ffa-tier-badge")!.calibrationGames, + ).toBe(3); + expect(el.querySelector("[data-ffa-rating-value]")).toBeNull(); + expect(el.querySelector("ofstats-credit")).not.toBeNull(); + }); + + it("renders nothing without a rating", async () => { + await render(null); + expect(el.textContent).toBe(""); + expect(el.querySelector("[data-ffa-rating-summary]")).toBeNull(); + }); +}); diff --git a/tests/client/PlayerProfileModal.test.ts b/tests/client/PlayerProfileModal.test.ts index 55f97ddd0fb..4fff3f92638 100644 --- a/tests/client/PlayerProfileModal.test.ts +++ b/tests/client/PlayerProfileModal.test.ts @@ -136,6 +136,54 @@ describe("public player profile route", () => { expect(window.location.hash).toBe(""); }); + it("shows another player's FFA rating", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue({ + createdAt: "2026-01-01T00:00:00.000Z", + stats: {}, + username: "Bob", + ffaRating: { + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + games: 40, + wins: 5, + peak: 1700, + }, + }); + history.replaceState(null, "", "/#modal=profile&publicID=bob"); + expect(modalRouter.routeFromHash()).toBe(true); + + await vi.waitFor(async () => { + await modal.updateComplete; + const summary = modal.querySelector< + HTMLElement & { updateComplete: Promise } + >("ffa-rating-summary"); + await summary?.updateComplete; + expect( + modal.querySelector("ffa-rating-summary [data-ffa-rating-value]")! + .textContent, + ).toContain("1613 ± 85"); + }); + }); + + it("shows no FFA rating when the profile has none", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue({ + createdAt: "2026-01-01T00:00:00.000Z", + stats: statsTree, + username: "Bob", + ffaRating: null, + }); + history.replaceState(null, "", "/#modal=profile&publicID=bob"); + expect(modalRouter.routeFromHash()).toBe(true); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.querySelector("player-stats-tree-view")).not.toBeNull(); + }); + expect(modal.querySelector("[data-ffa-rating-summary]")).toBeNull(); + expect(modal.textContent).not.toContain("ffa_rating.title"); + }); + it("shows not-found when the profile fetch fails", async () => { fetchPublicPlayerProfileMock.mockResolvedValue(false); history.replaceState(null, "", "/#modal=profile&publicID=missing"); diff --git a/tests/client/ProfileCard.test.ts b/tests/client/ProfileCard.test.ts index 04aa6edd67d..0d52bc55906 100644 --- a/tests/client/ProfileCard.test.ts +++ b/tests/client/ProfileCard.test.ts @@ -57,6 +57,41 @@ describe("", () => { expect(text("[data-profile-lifetime]")).toContain("212,400"); }); + it("shows the FFA rating on the full card", async () => { + const ffaRating = { + kind: "rated", + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + } as const; + await render({ progress: PRESTIGE_3, ffaRating }); + const summary = card.querySelector("ffa-rating-summary"); + expect(summary).not.toBeNull(); + expect((summary as unknown as { rating: unknown }).rating).toEqual( + ffaRating, + ); + }); + + it("shows no FFA rating without one", async () => { + await render({ progress: PRESTIGE_3 }); + expect(card.querySelector("[data-ffa-rating-summary]")).toBeNull(); + expect(card.textContent).not.toContain("ffa_rating.title"); + }); + + it("keeps the compact card free of the rating", async () => { + await render({ + variant: "compact", + progress: PRESTIGE_3, + ffaRating: { + kind: "rated", + rating: 1612.6, + uncertainty: 84.7, + tier: "Gold I", + }, + }); + expect(card.querySelector("ffa-rating-summary")).toBeNull(); + }); + it("never repeats the games and wins the stats below it show", async () => { await render({ username: "Iamlewis", progress: PRESTIGE_3 }); expect(card.textContent).not.toContain("player_stats_tree.stats_played"); From 036c9dda8f8e90b47d30fb0236b3ee746960413c Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:29:06 +0100 Subject: [PATCH 09/14] feat(client): FFA rating change in game history Co-Authored-By: Claude Opus 5.5 --- .../stats/PlayerGameHistoryView.ts | 35 +++++ .../client/PlayerGameHistoryFfaRating.test.ts | 125 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/client/PlayerGameHistoryFfaRating.test.ts diff --git a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts index 566aa5dc6ad..e1bb170c0fa 100644 --- a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts +++ b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts @@ -1,5 +1,6 @@ import { GameMapType } from "@openfront/engine-api/game/GameTypes"; import { + type FfaRatingGameDelta, type PlayerGameModeFilter, type PlayerGameTypeFilter, type PublicPlayerGame, @@ -24,6 +25,7 @@ import { } from "../../../Utils"; import { renderLoadingSpinner } from "../../BaseModal"; import "../../CopyButton"; +import { formatExpectedPct, formatRatingChange } from "../../ffaRating/FfaTier"; import { formatAbsoluteTime, formatDayHeader, @@ -581,6 +583,7 @@ export class PlayerGameHistoryView extends LitElement { translateText("clan_modal.history_duration"), renderDuration(game.durationSeconds), )} + ${game.ffaRating ? this.renderFfaRatingField(game.ffaRating) : ""}
`; @@ -599,6 +602,38 @@ export class PlayerGameHistoryView extends LitElement { `; } + // Only sent for a calibrated rated FFA seat, so the row's mode needs no check. + private renderFfaRatingField(r: FfaRatingGameDelta): TemplateResult { + const change = r.after - r.before; + const rounded = Math.round(change); + const colour = + rounded > 0 + ? "text-green-400" + : rounded < 0 + ? "text-red-400" + : "text-white/50"; + return html` +
+
+ ${translateText("ffa_rating.title")} +
+ ${formatRatingChange(change)} + ${translateText("ffa_rating.expected", { + pct: formatExpectedPct(r.expected), + })} +
+ `; + } + // The player's own outcome. "incomplete" (no recorded winner) gets a neutral // badge rather than collapsing into Defeat, so an unfinished game isn't // mislabelled as a loss in a personal history. diff --git a/tests/client/PlayerGameHistoryFfaRating.test.ts b/tests/client/PlayerGameHistoryFfaRating.test.ts new file mode 100644 index 00000000000..4f2f790f1a4 --- /dev/null +++ b/tests/client/PlayerGameHistoryFfaRating.test.ts @@ -0,0 +1,125 @@ +import type { FfaRatingGameDelta } from "@openfront/shared/ApiSchemas"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/Api", () => ({ + fetchPublicPlayerGames: vi.fn(), +})); + +vi.mock("../../src/client/TerrainMapFileLoader", () => ({ + terrainMapFileLoader: { + getMapData: vi.fn(() => { + throw new Error("unknown map"); + }), + }, +})); + +vi.mock("../../src/client/Utils", () => ({ + translateText: (k: string, p?: Record) => + p ? `${k}:${JSON.stringify(p)}` : k, + getMapName: () => null, + renderDuration: () => "10m", + copyToClipboard: vi.fn(), + showToast: vi.fn(), +})); + +class FakeIntersectionObserver { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + disconnect() {} + unobserve() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ""; + thresholds = []; +} +vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + +import { fetchPublicPlayerGames } from "../../src/client/Api"; +import type { PlayerGameHistoryView } from "../../src/client/components/baseComponents/stats/PlayerGameHistoryView"; +import "../../src/client/components/baseComponents/stats/PlayerGameHistoryView"; + +const game = (gameId: string, ffaRating?: FfaRatingGameDelta | null) => ({ + gameId, + start: "2026-10-20T00:00:00.000Z", + durationSeconds: 600, + map: "", + mode: "Free For All", + type: "Public", + playerTeams: null, + rankedType: "unranked", + result: "victory", + totalPlayers: 24, + username: "bob", + clanTag: null, + ffaRating, +}); + +let view: PlayerGameHistoryView; +afterEach(() => view?.remove()); + +async function renderHistory( + g: ReturnType, +): Promise { + vi.mocked(fetchPublicPlayerGames).mockResolvedValue({ + results: [g], + nextCursor: null, + } as never); + view = document.createElement( + "player-game-history-view", + ) as PlayerGameHistoryView; + view.publicId = "p1"; + document.body.appendChild(view); + await vi.waitFor(async () => { + await view.updateComplete; + expect(view.textContent).toContain("bob"); + }); + return view; +} + +describe(" FFA rating", () => { + it("shows the rating change, its split and expected win on a rated FFA row", async () => { + await renderHistory( + game("g1", { + before: 1600.4, + after: 1612.6, + expected: 0.0623, + deltaResult: 9.1, + deltaPlace: 3.1, + }), + ); + const chip = view.querySelector("[data-ffa-rating-change]")!; + expect(chip.textContent).toContain("+12"); + expect(chip.title).toBe("ffa_rating.result +9 · ffa_rating.placement +3"); + expect(chip.className).toContain("text-green-400"); + expect( + view.querySelector("[data-ffa-rating-expected]")!.textContent, + ).toContain('ffa_rating.expected:{"pct":"6.2"}'); + expect(view.textContent).toContain("ffa_rating.title"); + }); + + it("colours a loss red", async () => { + await renderHistory( + game("g3", { + before: 1600, + after: 1590, + expected: 0.2, + deltaResult: -8, + deltaPlace: -2, + }), + ); + const chip = view.querySelector("[data-ffa-rating-change]")!; + expect(chip.textContent).toContain("-10"); + expect(chip.className).toContain("text-red-400"); + }); + + it.each([null, undefined])( + "shows no rating on a row whose ffaRating is %s", + async (ffaRating) => { + await renderHistory(game("g2", ffaRating)); + expect(view.querySelector("[data-ffa-rating-change]")).toBeNull(); + expect(view.querySelector("[data-ffa-rating-expected]")).toBeNull(); + }, + ); +}); From a2a0bb6a66ae37835e282cd74a50d320dc103b63 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:36:53 +0100 Subject: [PATCH 10/14] fix(client): re-poll when the FFA rating panel is re-attached Co-Authored-By: Claude Opus 5.5 --- src/client/components/ffaRating/FfaRatingPanel.ts | 9 ++++++++- tests/client/FfaRatingPanel.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/client/components/ffaRating/FfaRatingPanel.ts b/src/client/components/ffaRating/FfaRatingPanel.ts index 9b58b1f6704..c558906d4f4 100644 --- a/src/client/components/ffaRating/FfaRatingPanel.ts +++ b/src/client/components/ffaRating/FfaRatingPanel.ts @@ -143,11 +143,18 @@ export class FfaRatingPanel extends LitElement { return this; } + connectedCallback() { + super.connectedCallback(); + // Lit schedules no update on re-attach, so ask for one: updated() then + // polls again. + if (this.hasUpdated) this.requestUpdate(); + } + disconnectedCallback() { super.disconnectedCallback(); this.abort?.abort(); this.abort = null; - // A re-attached panel asks again. + // A re-attached panel asks again (connectedCallback requests the update). this.polledGameId = null; } diff --git a/tests/client/FfaRatingPanel.test.ts b/tests/client/FfaRatingPanel.test.ts index 71bbc2060d0..f73cda58e8d 100644 --- a/tests/client/FfaRatingPanel.test.ts +++ b/tests/client/FfaRatingPanel.test.ts @@ -238,4 +238,18 @@ describe("", () => { panel.remove(); expect(signal.aborted).toBe(true); }); + + it("asks again when removed and re-attached", async () => { + const poll = vi.fn( + (_id: string, _signal: AbortSignal) => + new Promise(() => {}), + ); + await render(poll); + expect(poll).toHaveBeenCalledTimes(1); + panel.remove(); + document.body.appendChild(panel); + await panel.updateComplete; + expect(poll).toHaveBeenCalledTimes(2); + expect(poll.mock.calls[1][1].aborted).toBe(false); + }); }); From 7538294509a8ff3e6011010e49988fffcb4aa50a Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:37:43 +0100 Subject: [PATCH 11/14] fix(client): no FFA rating panel for unrateable games A nation, tribe or team win and a viewer who never spawned are never rated. Co-Authored-By: Claude Opus 5.5 --- .../ffaRating/FfaRatingEligibility.ts | 5 ++-- src/client/hud/layers/WinModal.ts | 3 +++ tests/client/FfaRatingEligibility.test.ts | 11 +++++++-- tests/client/graphics/layers/WinModal.test.ts | 23 +++++++++++++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/client/components/ffaRating/FfaRatingEligibility.ts b/src/client/components/ffaRating/FfaRatingEligibility.ts index 06940a867c8..e3965238834 100644 --- a/src/client/components/ffaRating/FfaRatingEligibility.ts +++ b/src/client/components/ffaRating/FfaRatingEligibility.ts @@ -18,7 +18,8 @@ export interface FfaRatingGame { /** * The game as an FFA rating candidate: a public, unranked FFA game that the - * viewer played (not a replay, not as a spectator). null for anything else. + * viewer played (not a replay, not as a spectator, and spawned in). null for + * anything else. */ export function ffaRatingGameOf( game: Pick, @@ -30,7 +31,7 @@ export function ffaRatingGameOf( gameMode !== GameMode.FFA || rankedType !== undefined || config.isReplay() || - game.myPlayer() === null + game.myPlayer()?.hasSpawned() !== true ) { return null; } diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index a4084555b93..9cfabe53f66 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -1095,6 +1095,9 @@ export class WinModal extends LitElement implements Controller { try { const ffaGame = ffaRatingGameOf(this.game); if (ffaGame === null) return; + // Only a human winner ends a rated game: a nation or tribe win, a team + // win or a cancelled match is never rated. + if (this.gameEnd?.winner?.[0] !== "player") return; this.ffaRatingChecked = true; const account = await resolveXpAccount(); if (account.kind !== "signed_in" || !viewerHasFfaRating(account.me)) { diff --git a/tests/client/FfaRatingEligibility.test.ts b/tests/client/FfaRatingEligibility.test.ts index 00e3da36bf4..3908f59db21 100644 --- a/tests/client/FfaRatingEligibility.test.ts +++ b/tests/client/FfaRatingEligibility.test.ts @@ -13,7 +13,12 @@ import { const game = ( cfg: Partial, - opts: { replay?: boolean; me?: boolean; humans?: number } = {}, + opts: { + replay?: boolean; + me?: boolean; + spawned?: boolean; + humans?: number; + } = {}, ) => ({ gameID: () => "g1", @@ -26,7 +31,8 @@ const game = ( }), isReplay: () => opts.replay ?? false, }), - myPlayer: () => ((opts.me ?? true) ? {} : null), + myPlayer: () => + (opts.me ?? true) ? { hasSpawned: () => opts.spawned ?? true } : null, players: () => [ ...Array.from({ length: opts.humans ?? 12 }, () => ({ type: () => PlayerType.Human, @@ -52,6 +58,7 @@ describe("ffaRatingGameOf", () => { ["an old ranked FFA game", game({ rankedType: RankedType.FreeForAll })], ["a replay", game({}, { replay: true })], ["a spectator", game({}, { me: false })], + ["a viewer who never spawned", game({}, { spawned: false })], ])("has no rating for %s", (_, g) => { expect(ffaRatingGameOf(g)).toBeNull(); }); diff --git a/tests/client/graphics/layers/WinModal.test.ts b/tests/client/graphics/layers/WinModal.test.ts index b1b681afd95..c8c2b3a240f 100644 --- a/tests/client/graphics/layers/WinModal.test.ts +++ b/tests/client/graphics/layers/WinModal.test.ts @@ -4,6 +4,7 @@ import { PlayerType, RankedType, } from "@openfront/engine-api/game/GameTypes"; +import type { Winner } from "@openfront/engine-api/Schemas"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FfaRatingPanel } from "../../../../src/client/components/ffaRating/FfaRatingPanel"; import type { PurchaseButton } from "../../../../src/client/components/PurchaseButton"; @@ -264,7 +265,10 @@ describe("WinModal FFA rating panel", () => { me: { player: { trustTier: "trusted", leaderboard: { ffa } } }, } as never); - const mount = async (rankedType?: RankedType) => { + const mount = async ( + rankedType?: RankedType, + winner: Winner = ["player", "c1"], + ) => { const el = document.createElement("win-modal") as WinModal; el.game = { gameID: () => "g1", @@ -276,13 +280,19 @@ describe("WinModal FFA rating panel", () => { }), isReplay: () => false, }), - myPlayer: () => ({ isAlive: () => true }), + myPlayer: () => ({ isAlive: () => true, hasSpawned: () => true }), players: () => Array.from({ length: 12 }, () => ({ type: () => PlayerType.Human, hasSpawned: () => true, })), } as never; + // What vote() records when the Win update arrives. + (el as unknown as { gameEnd: unknown }).gameEnd = { + winner, + allPlayersStats: {}, + tick: 0, + }; document.body.appendChild(el); await el.updateComplete; modal = el; @@ -310,6 +320,15 @@ describe("WinModal FFA rating panel", () => { expect(m.querySelector("ffa-rating-panel")).toBeNull(); }); + it("shows no panel when a nation won", async () => { + signedIn({ calibrationGames: 3 }); + const m = await mount(undefined, ["nation", "Atlantis"]); + await m.updateFfaRating(); + await m.updateComplete; + expect(m.querySelector("ffa-rating-panel")).toBeNull(); + expect(resolveXpAccount).not.toHaveBeenCalled(); + }); + it("shows no panel after a ranked game", async () => { signedIn({ calibrationGames: 3 }); const m = await mount(RankedType.OneVOne); From 7206e4a39383540c9f93533ab1c358be4de1a0f8 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:37:56 +0100 Subject: [PATCH 12/14] docs(shared): name the real FFA game-rating endpoint in its schema comment Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ApiSchemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 37212053db7..8b23d305afa 100644 --- a/packages/shared/src/ApiSchemas.ts +++ b/packages/shared/src/ApiSchemas.ts @@ -276,7 +276,7 @@ export type GameXpEligible = z.infer; export type GameXpIneligible = z.infer; export type GameXpResponse = z.infer; -// GET /game/:id/ffa-rating — what one FFA game did to the caller's rating. +// GET /users/@me/ffa_rating/:gameId — what one FFA game did to the caller's rating. // While calibrating (`calibrating: true`) only the games count is sent; once // rated, the before/after numbers and tiers follow. Strict, unlike the fields // added to the lists above: the caller treats a body that fails to parse as From d32f150f6992e65c84732540d07b11d43c0f5aae Mon Sep 17 00:00:00 2001 From: iamlewis Date: Thu, 22 Oct 2026 21:38:40 +0100 Subject: [PATCH 13/14] fix(client): round the Elo of an untiered row on a live FFA board Co-Authored-By: Claude Opus 5.5 --- .../leaderboard/LeaderboardPlayerList.ts | 2 +- tests/client/LeaderboardModal.test.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index aa6dc82f67e..2ce189a81ab 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -478,7 +478,7 @@ export class LeaderboardPlayerList extends LitElement { const weight = strong ? "font-bold" : "font-medium"; if (!this.ffaRatingLive || row.tier === undefined) { return html`${row.elo}${this.ffaRatingLive ? formatRating(row.elo) : row.elo}`; } return html` diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index aeb50714e6e..83cd6ed7954 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -742,6 +742,29 @@ describe("LeaderboardModal", () => { expect(modal.querySelector("ofstats-credit")).not.toBeNull(); }); + it("rounds the rating of a row whose tier was dropped on a live board", async () => { + fetchMock().mockResolvedValueOnce( + jsonRes({ + "1v1": [], + "2v2": [], + ffa: [ + rated(1, "p4", 2012.6, "Diamond II"), + // The schema drops a malformed tier (the mocked fetch here skips + // it), so such a row arrives with no tier and an unrounded Elo. + entry(2, "p5", 1850.7), + ], + }), + ); + await getPlayerList()!.loadPlayerLeaderboard(true); + await showLadder("playersFfa"); + expect(modal.querySelectorAll("[data-ffa-rating-cell]")).toHaveLength(1); + const text = modal.querySelector( + "leaderboard-player-list tbody", + )!.textContent!; + expect(text).toContain("1851"); + expect(text).not.toContain("1850.7"); + }); + it("shows the frozen FFA ladder plainly", async () => { fetchMock().mockResolvedValueOnce( jsonRes({ "1v1": [], "2v2": [], ffa: [entry(1, "p4", 1300)] }), From e096a95585200cf8f830fd5accca5dccf32dd592 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 23 Oct 2026 10:39:05 +0100 Subject: [PATCH 14/14] fix(client): fit the FFA rating column on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone the live FFA board's rating column was cut off until you scrolled sideways, and "± 48" could wrap onto its own line. Below the sm breakpoint the tier badge now sits above the rating, the column narrows to 8rem so it shows without scrolling, and the rating and its uncertainty never wrap apart. Desktop is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../leaderboard/LeaderboardPlayerList.ts | 31 ++++++++++++------- tests/client/LeaderboardModal.test.ts | 11 +++++-- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index 2ce189a81ab..bceb2c4f999 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -473,7 +473,9 @@ export class LeaderboardPlayerList extends LitElement { // The rating column. On a live FFA board a rated row shows its tier badge // and "rating ± uncertainty"; every other ladder (and an FFA board whose - // rows carry no tier) shows the plain Elo number, exactly as before. + // rows carry no tier) shows the plain Elo number, exactly as before. On a + // phone the badge sits above the number, so the column stays narrow enough + // to show without scrolling sideways. private renderRatingCell(row: LadderRow, strong: boolean): TemplateResult { const weight = strong ? "font-bold" : "font-medium"; if (!this.ffaRatingLive || row.tier === undefined) { @@ -482,16 +484,21 @@ export class LeaderboardPlayerList extends LitElement { >`; } return html` -
+
- ${formatRating(row.elo)} - ${row.uncertainty === undefined - ? nothing - : html`± ${formatRating(row.uncertainty)}`} + + ${formatRating(row.elo)} + ${row.uncertainty === undefined + ? nothing + : html`± ${formatRating(row.uncertainty)}`} +
`; } @@ -714,7 +721,9 @@ export class LeaderboardPlayerList extends LitElement { - + ${ratingLive + ? html`` + : html``} diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index 83cd6ed7954..43694440c9f 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -781,7 +781,7 @@ describe("LeaderboardModal", () => { expect(cols[2].style.width).toBe("6rem"); }); - it("widens the rating column once rows carry a tier", async () => { + it("widens the rating column, less on phones, once rows carry a tier", async () => { fetchMock().mockResolvedValueOnce( jsonRes({ "1v1": [], @@ -794,7 +794,14 @@ describe("LeaderboardModal", () => { const cols = modal.querySelectorAll( "leaderboard-player-list col", ); - expect(cols[2].style.width).toBe("12rem"); + expect(cols[2].style.width).toBe(""); + expect(cols[2].classList).toContain("w-32"); + expect(cols[2].classList).toContain("sm:w-48"); + // The rating and its uncertainty never wrap apart. + const cell = modal.querySelector("[data-ffa-rating-cell]")!; + const value = cell.querySelector("[data-ffa-rating-value]")!; + expect(value.classList).toContain("whitespace-nowrap"); + expect(value.textContent).toContain("± 61"); }); it("pins a calibrating row for a signed-in player not on the board", async () => {