Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/shared/src/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,28 @@ export type GameXpEligible = z.infer<typeof GameXpEligibleSchema>;
export type GameXpIneligible = z.infer<typeof GameXpIneligibleSchema>;
export type GameXpResponse = z.infer<typeof GameXpResponseSchema>;

// 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<typeof FfaGameRatingResponseSchema>;

// GET /public/player/:publicId/progress — another player's level. No auth.
export const PublicProgressSchema = z.object({
prestige: z.number(),
Expand Down Expand Up @@ -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(),
})
Expand Down Expand Up @@ -1155,6 +1191,17 @@ export const PlayerStatsTreeSchema = z.object({
});
export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;

// 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<typeof PublicFfaRatingSchema>;

export const PlayerProfileSchema = z.object({
createdAt: z.iso.datetime(),
user: DiscordUserSchema.optional(),
Expand Down Expand Up @@ -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<typeof PlayerProfileSchema>;

Expand Down Expand Up @@ -1213,6 +1264,16 @@ export const PlayerGameResultSchema = z.enum([
]);
export type PlayerGameResult = z.infer<typeof PlayerGameResultSchema>;

// 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<typeof FfaRatingGameDeltaSchema>;

export const PublicPlayerGameSchema = z.object({
gameId: z.string(),
start: z.iso.datetime(),
Expand All @@ -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<typeof PublicPlayerGameSchema>;

Expand Down Expand Up @@ -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<
Expand Down
25 changes: 24 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`<ffa-rating-summary
class="mb-4 block"
.rating=${ffaRating}
></ffa-rating-summary>`
: 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).
Expand All @@ -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}
Expand Down
83 changes: 83 additions & 0 deletions src/client/FfaRatingApi.ts
Original file line number Diff line number Diff line change
@@ -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<FfaGameRatingFetchResult> {
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<FfaGameRatingResponse | null> {
return pollUntilSettled(
(signal) => (opts.fetcher ?? fetchMyFfaGameRating)(gameId, signal),
opts,
);
}
2 changes: 1 addition & 1 deletion src/client/LeaderboardModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down
17 changes: 16 additions & 1 deletion src/client/PlayerProfileModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +60,7 @@ export class PlayerProfileModal extends BaseModal {
@state() private statsTree: PlayerStatsTree | null = null;
@state() private clans: NonNullable<PlayerProfile["clans"]> = [];
@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;
Expand Down Expand Up @@ -312,13 +318,19 @@ export class PlayerProfileModal extends BaseModal {
const view = this.progressView();
return html`
${view === null
? nothing
? this.ffaRating === null
? nothing
: html`<ffa-rating-summary
class="mb-4 block"
.rating=${this.ffaRating}
></ffa-rating-summary>`
: html`<profile-card
class="mb-4 block"
.username=${this.username ?? this.publicId ?? ""}
.clanTag=${this.displayClanTag}
.progress=${view.progress}
.provisional=${view.provisional}
.ffaRating=${this.ffaRating}
.openKey=${`profile-${this.openCount}`}
></profile-card>`}
<profile-share
Expand Down Expand Up @@ -494,6 +506,7 @@ export class PlayerProfileModal extends BaseModal {
this.statsTree = null;
this.clans = [];
this.displayClanTag = null;
this.ffaRating = null;
this.progress = null;
this.gameHistoryCache = null;
this.gamesScrollTop = 0;
Expand Down Expand Up @@ -548,6 +561,8 @@ export class PlayerProfileModal extends BaseModal {
this.clans = profile === false ? [] : (profile.clans ?? []);
this.displayClanTag =
profile === false ? null : (profile.displayClanTag ?? null);
this.ffaRating =
profile === false ? null : ffaSummaryFromProfile(profile.ffaRating);
}

// Intentionally preserves publicId/statsTree/history cache/scroll: the page
Expand Down
Loading
Loading