diff --git a/packages/shared/src/ApiSchemas.ts b/packages/shared/src/ApiSchemas.ts index 14ebcdacea7..8b23d305afa 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 /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 +// "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/resources/lang/en.json b/resources/lang/en.json index 07a689be365..76dc6f79ba6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -928,6 +928,29 @@ "minimize": "Minimize", "open_on_twitch": "Open {channel} on Twitch" }, + "ffa_rating": { + "calibrating": "Calibrating {n}/10", + "calibration_complete": "Calibration complete", + "credit": "Rating by OFstats", + "expected": "Expected win: {pct}%", + "peak": "Peak {rating}", + "pending": "Calculating rating…", + "placement": "Placement", + "rating": "Rating", + "result": "Result", + "tier": { + "bronze": "Bronze", + "diamond": "Diamond", + "gold": "Gold", + "grandmaster": "Grandmaster", + "iron": "Iron", + "master": "Master", + "platinum": "Platinum", + "silver": "Silver" + }, + "tier_up": "Promoted to {tier}", + "title": "FFA Rating" + }, "free_play": { "account": "Your account, stats, friends and cosmetics", "ad_free_steam": "No ads in the Steam app, ever", @@ -1411,6 +1434,7 @@ "clans_tab": "Clans", "elo": "ELO", "error": "Error loading leaderboard", + "ffa_rating_tab": "FFA Rating", "games": "Games", "loss_score": "Loss Score", "loss_score_tooltip": "Weighted losses based on clan participation and match difficulty", @@ -1419,7 +1443,6 @@ "player": "Player", "rank": "Rank", "ranked_2v2_tab": "2v2 Ranked", - "ranked_ffa_tab": "FFA Ranked", "ranked_no_stats": "No ranked games have been played on this ladder yet", "ranked_tab": "1v1 Ranked", "ratio": "Ratio", 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/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/LeaderboardModal.ts b/src/client/LeaderboardModal.ts index d366e75327f..70506804602 100644 --- a/src/client/LeaderboardModal.ts +++ b/src/client/LeaderboardModal.ts @@ -61,7 +61,7 @@ export class LeaderboardModal extends BaseModal { }, { key: "playersFfa", - label: translateText("leaderboard_modal.ranked_ffa_tab"), + label: translateText("leaderboard_modal.ffa_rating_tab"), }, { key: "clans", label: translateText("leaderboard_modal.clans_tab") }, { key: "tribes", label: translateText("leaderboard_modal.tribes_tab") }, 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``} = + | { 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/src/client/components/ProfileCard.ts b/src/client/components/ProfileCard.ts index 5f6295787be..e8c96ff28b3 100644 --- a/src/client/components/ProfileCard.ts +++ b/src/client/components/ProfileCard.ts @@ -11,6 +11,8 @@ import { customElement, property, state } from "lit/decorators.js"; import { clampPrestige, levelFraction } from "../Progression"; import { prefersReducedMotion } from "../utilities/ReducedMotion"; import { translateText } from "../Utils"; +import "./ffaRating/FfaRatingSummary"; +import type { FfaRatingSummaryData } from "./ffaRating/FfaRatingSummary"; import "./LevelBadge"; import { levelBadgeAccent } from "./LevelBadge"; import { splitAccountUsername, usernameText } from "./ui/UsernameText"; @@ -104,6 +106,8 @@ export class ProfileCard extends LitElement { // The progress counts XP from games still waiting to be scored: the card // says it's provisional. @property({ type: Boolean }) provisional = false; + // The player's FFA rating, on the full card only. Null: nothing shows. + @property({ attribute: false }) ffaRating: FfaRatingSummaryData | null = null; // Names one opening of the page the card is on: the flourish plays once per // key, however often the card is rendered again (a tab switch builds a new // one). Without a key it plays once per card. @@ -527,6 +531,12 @@ export class ProfileCard extends LitElement {
${clan} ${name} ${this.renderLevelLine(progress)} ${this.renderProvisional()} ${this.renderXp(progress)} + ${this.ffaRating === null + ? nothing + : html``} ${this.canPrestige(progress) ? html`
`; @@ -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/src/client/components/ffaRating/FfaRatingEligibility.ts b/src/client/components/ffaRating/FfaRatingEligibility.ts new file mode 100644 index 00000000000..e3965238834 --- /dev/null +++ b/src/client/components/ffaRating/FfaRatingEligibility.ts @@ -0,0 +1,54 @@ +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, and spawned in). 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()?.hasSpawned() !== true + ) { + 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/components/ffaRating/FfaRatingPanel.ts b/src/client/components/ffaRating/FfaRatingPanel.ts new file mode 100644 index 00000000000..c558906d4f4 --- /dev/null +++ b/src/client/components/ffaRating/FfaRatingPanel.ts @@ -0,0 +1,308 @@ +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; + } + + 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 (connectedCallback requests the update). + 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/src/client/components/ffaRating/FfaRatingSummary.ts b/src/client/components/ffaRating/FfaRatingSummary.ts new file mode 100644 index 00000000000..40b2ac1b3a7 --- /dev/null +++ b/src/client/components/ffaRating/FfaRatingSummary.ts @@ -0,0 +1,125 @@ +import type { + PublicFfaRating, + UserMeResponse, +} from "@openfront/shared/ApiSchemas"; +import { LitElement, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../../Utils"; +import { formatRating, formatRatingWithUncertainty } from "./FfaTier"; +import "./FfaTierBadge"; +import "./OfstatsCredit"; + +/** The `leaderboard.ffa` entry of `/users/@me`. */ +export type MeFfaEntry = NonNullable< + NonNullable["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/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/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/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index ec8cf6d94bc..bceb2c4f999 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -3,18 +3,39 @@ import { PlayerLeaderboardEntry, RankedLeaderboardEntry, } from "@openfront/shared/ApiSchemas"; -import { html, LitElement, nothing, PropertyValues } from "lit"; +import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { fetchPlayerLeaderboard, getUserMe } from "../../Api"; import { loadModal } from "../../LazyModals"; import { translateText } from "../../Utils"; +import "../ffaRating/FfaTierBadge"; +import { formatRating } from "../ffaRating/FfaTier"; +import "../ffaRating/OfstatsCredit"; import { isPrestigedOrLegend, levelBadgeSlot } from "../LevelBadgeSlot"; import "../PlayerName"; +/** + * A ladder row. `uncertainty` and `tier` are present only once the API serves + * the FFA rating; rows without them render as the plain Elo ladder. + */ +type LadderRow = PlayerLeaderboardEntry & { + uncertainty?: number; + tier?: string; +}; + +/** The signed-in player's FFA progress while they have no tier yet. */ +interface FfaCalibration { + publicId: string; + username: string | null; + calibrationGames: number; + games: number; + wins: number; +} + /** One ranked ladder's loaded rows. */ interface LadderState { - entries: PlayerLeaderboardEntry[]; - userEntry: PlayerLeaderboardEntry | null; + entries: LadderRow[]; + userEntry: LadderRow | null; hasMore: boolean; } @@ -34,9 +55,7 @@ const emptyLadders = (): Record => ({ [RankedType.FreeForAll]: { entries: [], userEntry: null, hasMore: true }, }); -const toPlayerEntry = ( - entry: RankedLeaderboardEntry, -): PlayerLeaderboardEntry => ({ +const toPlayerEntry = (entry: RankedLeaderboardEntry): LadderRow => ({ rank: entry.rank, playerId: entry.public_id, accountUsername: entry.accountUsername, @@ -48,6 +67,8 @@ const toPlayerEntry = ( level: entry.level, prestige: entry.prestige, legend: entry.legend, + uncertainty: entry.uncertainty, + tier: entry.tier, }); @customElement("leaderboard-player-list") @@ -72,17 +93,27 @@ export class LeaderboardPlayerList extends LitElement { private currentPage = 1; private currentUserId: string | null = null; private currentUserIdLoaded = false; + @state() private ffaCalibration: FfaCalibration | null = null; @query(".scroll-container") private scrollContainer?: HTMLElement; - private get playerData(): PlayerLeaderboardEntry[] { + private get playerData(): LadderRow[] { return this.ladders[this.rankedType].entries; } - private get currentUserEntry(): PlayerLeaderboardEntry | null { + private get currentUserEntry(): LadderRow | null { return this.ladders[this.rankedType].userEntry; } + // The FFA tab shows ratings (badge, rating ± uncertainty, OFstats credit) + // only once the API sends them. Until then it is the plain Elo ladder. + private get ffaRatingLive(): boolean { + return ( + this.rankedType === RankedType.FreeForAll && + this.playerData.some((row) => row.tier !== undefined) + ); + } + private get playerHasMore(): boolean { return this.ladders[this.rankedType].hasMore; } @@ -157,6 +188,20 @@ export class LeaderboardPlayerList extends LitElement { this.currentUserIdLoaded = true; const userMe = await getUserMe(); this.currentUserId = userMe ? userMe.player.publicId : null; + // A calibrating player has no tier, so the board does not list them. + const ffa = userMe ? userMe.player.leaderboard?.ffa : undefined; + this.ffaCalibration = + userMe && + ffa?.calibrationGames !== undefined && + ffa.tier === undefined + ? { + publicId: userMe.player.publicId, + username: userMe.player.username ?? null, + calibrationGames: ffa.calibrationGames, + games: ffa.games ?? 0, + wins: ffa.wins ?? 0, + } + : null; } // One page carries a slice of every ladder, so fold them all in — even @@ -309,6 +354,13 @@ export class LeaderboardPlayerList extends LitElement { // be reproduced by a separate element; and below 34rem the table scrolls // horizontally, which an overlay outside .scroll-container would not follow. private renderStickyUserRow(badgeSlot: boolean) { + if ( + this.rankedType === RankedType.FreeForAll && + this.currentUserEntry === null && + this.ffaCalibration !== null + ) { + return this.renderCalibratingRow(this.ffaCalibration); + } if (!this.currentUserEntry || !this.showStickyUser) return nothing; const entry = this.currentUserEntry; @@ -341,7 +393,7 @@ export class LeaderboardPlayerList extends LitElement {
- ${entry.elo} + ${this.renderRatingCell(entry, true)} ${entry.games} @@ -362,7 +414,96 @@ export class LeaderboardPlayerList extends LitElement { `; } - private renderPlayerRow(player: PlayerLeaderboardEntry, badgeSlot: boolean) { + // A player still calibrating has no tier and so no row on the board; this + // pinned row stands in for it. It is always shown: there is no listed row to + // scroll to. + private renderCalibratingRow(me: FfaCalibration) { + const ratio = + me.games > 0 ? `${((me.wins / me.games) * 100).toFixed(1)}%` : "—"; + + return html` + + + +
+ — +
+ + +
+ ${translateText("leaderboard_modal.your_ranking")} + this.openProfile(me.publicId)} + > +
+ + + + + + ${me.games} + + +
+ ${ratio} + ${translateText("leaderboard_modal.ratio")} +
+ + + + `; + } + + // 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. 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) { + return html`${this.ffaRatingLive ? formatRating(row.elo) : row.elo}`; + } + return html` +
+ + + ${formatRating(row.elo)} + ${row.uncertainty === undefined + ? nothing + : html`± ${formatRating(row.uncertainty)}`} + +
+ `; + } + + private renderPlayerRow(player: LadderRow, badgeSlot: boolean) { const isCurrentUser = this.currentUserEntry?.playerId === player.playerId; const displayRank = player.rank; @@ -407,7 +548,7 @@ export class LeaderboardPlayerList extends LitElement { - ${player.elo} + ${this.renderRatingCell(player, false)} ${player.games} @@ -556,6 +697,7 @@ export class LeaderboardPlayerList extends LitElement { // names line up — but only once some loaded row in this ladder has a // badge, so the board is unchanged until then. const badgeSlot = this.playerData.some(isPrestigedOrLegend); + const ratingLive = this.ffaRatingLive; return html`
@@ -565,17 +707,23 @@ export class LeaderboardPlayerList extends LitElement { stray outline. The rows above it end on their own border-b. -->
this.handleScroll()} > - + ${ratingLive + ? html`` + : html``} @@ -590,7 +738,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 +761,11 @@ export class LeaderboardPlayerList extends LitElement {
${this.renderPlayerFooter()}
+ ${ratingLive + ? html`` + : nothing}
`; diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 7a290973da5..9cfabe53f66 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()}