Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
53b26e0
Show player levels and the XP each game earns
iiamlewis Sep 30, 2026
6ff9abf
Strip Lit's comment markers in the badge tests as nodes, not by regex
iiamlewis Sep 30, 2026
c91fed6
Merge branch 'main' into player-levels-ui
iiamlewis Oct 1, 2026
0ef94ba
Harden player levels UI from PR review
iiamlewis Oct 1, 2026
23be312
Merge branch 'main' into player-levels-ui
iiamlewis Oct 2, 2026
b899cb0
Address review on player levels: unverified games, off-screen reveals…
iiamlewis Oct 2, 2026
216851d
Rename the first-win XP line to first game of the day
iiamlewis Oct 2, 2026
a9c27cb
Recognise the new subscriber XP boosts: +25% / +50% / +100%
iiamlewis Oct 2, 2026
399acf7
feat: stamp player levels onto the lobby roster
iiamlewis Oct 2, 2026
0e2d49c
feat: show level badges in the lobby list and player panel
iiamlewis Oct 2, 2026
4c24467
feat(server): leave the level badge off players who hide their level
iiamlewis Oct 3, 2026
ae66131
feat(client): a player who hides their level still sees their own badge
iiamlewis Oct 3, 2026
fbaea1f
feat(client): Privacy card in account settings to hide your level
iiamlewis Oct 3, 2026
bfbcbda
perf: benchmark the lobby roster's level badges
iiamlewis Oct 3, 2026
91e13d6
fix(core): read a malformed levelHidden as absent
iiamlewis Oct 3, 2026
08e0bfb
feat(client): "Show my profile in search engines" in the Privacy card
iiamlewis Oct 3, 2026
46dd844
fix(client): a player who hides their level sees it on their own profile
iiamlewis Oct 3, 2026
5f83874
Stack the XP reveal's counter and level progress on a narrow bar
iiamlewis Oct 3, 2026
6a758f1
Show the XP a past game earned under its summary card
iiamlewis Oct 3, 2026
2edf46b
perf: carry the roster's level badge as one packed integer
iiamlewis Oct 5, 2026
ad04ccf
Merge roster-levels (packed level badge) into level-optout
iiamlewis Oct 5, 2026
be3a39c
Merge level-optout (packed level badge) into search-optout
iiamlewis Oct 5, 2026
f6bf428
perf: draw a full lobby's level badges over a few frames
iiamlewis Oct 5, 2026
cc30999
Merge roster-levels (staggered badge fill) into level-optout
iiamlewis Oct 5, 2026
ed22293
Merge level-optout (staggered badge fill) into search-optout
iiamlewis Oct 5, 2026
c93e4b0
Merge main: src/core split into engine, engine-api and shared packages
iiamlewis Oct 6, 2026
0a960f1
Merge player-levels-ui: src/core split into packages
iiamlewis Oct 6, 2026
80705bc
Merge roster-levels: src/core split into packages
iiamlewis Oct 6, 2026
a19f39f
Merge level-optout: src/core split into packages
iiamlewis Oct 6, 2026
c1f80da
Merge remote-tracking branch 'origin/main' into player-levels-ui
iiamlewis Oct 6, 2026
d1ae378
Point the XP popup test at EventBus's new home in @openfront/shared
iiamlewis Oct 6, 2026
88a6c74
Merge player-levels-ui: bring the branch up to date with main
iiamlewis Oct 6, 2026
fc73327
Merge remote-tracking branch 'origin/roster-levels' into level-optout
iiamlewis Oct 6, 2026
95c86ba
Merge remote-tracking branch 'origin/level-optout' into search-optout
iiamlewis Oct 6, 2026
1271d1f
Merge main: lazy-loaded homepage modals
iiamlewis Oct 6, 2026
5ae7770
Hide my level: a malformed setting reads as hidden; say it takes a while
iiamlewis Oct 8, 2026
e225511
Merge level-optout (#5771 review fixes); a malformed searchHidden rea…
iiamlewis Oct 8, 2026
a22ad43
Privacy toggles: one write path for level and search visibility
iiamlewis Oct 8, 2026
1f01c85
Honour "hide my level" on the roster badge; own badge after a refresh
iiamlewis Oct 8, 2026
812cc15
Address review on the player levels UI
iiamlewis Oct 8, 2026
049232e
Merge roster-levels (#5763 review fixes): the hide-my-level guard now…
iiamlewis Oct 8, 2026
c1b833f
Merge player-levels-ui (review fixes)
iiamlewis Oct 8, 2026
9f571f5
Merge roster-levels (review fixes)
iiamlewis Oct 8, 2026
667c87e
Merge level-optout (review fixes)
iiamlewis Oct 8, 2026
0111c2c
Merge main (#5726 and #5778 landed)
iiamlewis Oct 9, 2026
560c0a3
Merge roster-levels (main sync)
iiamlewis Oct 9, 2026
84f746e
Merge level-optout (main sync)
iiamlewis Oct 9, 2026
830c69d
Merge origin/main into level-optout
iiamlewis Oct 9, 2026
6cffd4f
Keep the own-level fallback in step with the setting and the session
iiamlewis Oct 9, 2026
1eaacc8
Pin levelBadge as ClientInfo's last field
iiamlewis Oct 9, 2026
53ff155
Move the lobby badge perf harnesses to their own PR
iiamlewis Oct 9, 2026
0bd325b
Merge branch 'level-optout' into search-optout
iiamlewis Oct 9, 2026
61c86ac
Own level badge: only the latest refresh writes the cache
iiamlewis Oct 9, 2026
f50c230
Merge level-optout (own badge refresh order)
iiamlewis Oct 9, 2026
8fba939
Merge origin/main into level-optout
iiamlewis Oct 11, 2026
6180838
Merge level-optout into search-optout
iiamlewis Oct 11, 2026
d539cbb
Merge search-optout (#5779) into level-optout (#5771)
iiamlewis Oct 11, 2026
9e663f5
Privacy settings: drop old-API fallbacks
iiamlewis Oct 12, 2026
d9443c5
Privacy settings: keep the guard for an API without the settings
iiamlewis Oct 12, 2026
afac379
Merge branch 'main' into level-optout
Celant Oct 12, 2026
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
12 changes: 12 additions & 0 deletions packages/shared/src/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,12 @@ export const UserMeResponseSchema = z.object({
// never fail the parse and reject the join, and when the setting can't be
// read the private answer is the safe one.
levelHidden: z.boolean().optional().catch(true),
// "Show my profile in search engines" turned off: the public profile page
// (openfront.io/player/<publicId>) is served noindex. It still opens for
// anyone with the link, and link previews are unchanged. Optional so an
// older API without the setting still parses — absent hides the row in
// account settings. A malformed value reads as hidden, like levelHidden.
searchHidden: z.boolean().optional().catch(true),
clans: z
.array(
z.object({
Expand Down Expand Up @@ -618,6 +624,12 @@ export const UserMeResponseSchema = z.object({
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;

// PUT /users/@me/level_visibility and /users/@me/search_visibility { hidden }
// — the stored setting, echoed.
export const VisibilityResponseSchema = z.object({
hidden: z.boolean(),
});
export type UserSubscription = NonNullable<
NonNullable<UserMeResponse["player"]["subscription"]>
>;
Expand Down
20 changes: 10 additions & 10 deletions packages/shared/src/WireSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,16 @@ export const ClientInfoSchema = z.object({
// prestige and legend packed into one varint by packLevelBadge
// (LevelBadgeWire.ts); read it with unpackLevelBadge. Stamped by the game
// server from its own /users/@me lookup, never taken from the client.
// Display-only. Absent for guests, for players who hide their level (on
// every copy of the roster, their own included), when the API has
// progression off or the lookup failed, and on anonymized entries (a badge
// beside an anonymous name would point out the veterans). Must stay the
// LAST field: its one presence bit is the eighth and last bit of this
// object's one-byte header, so an entry without it encodes byte-identically
// to one from before it existed, and a pre-badge frame still decodes.
// Another field here would need a second header byte. No range check here
// on purpose: one bad value must not fail the whole roster's parse, so
// unpackLevelBadge drops it instead.
// Display-only. Absent for guests, for players who hide their level (even
// from themselves: their own client draws its own badge from its local
// /users/@me instead), when the API has progression off or the lookup
// failed, and on anonymized entries (a badge beside an anonymous name would
// point out the veterans). Must stay the LAST field: its one presence
// bit is the eighth and last bit of this object's one-byte header, so an
// entry without it encodes byte-identically to one from before it existed,
// and a pre-badge frame still decodes. Another field here would need a
// second header byte. No range check here on purpose: one bad value must
// not fail the whole roster's parse, so unpackLevelBadge drops it instead.
levelBadge: zb.uint().optional(),
});

Expand Down
9 changes: 9 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
"identity_token_rate_limited": "Too many requests. Please wait a minute and try again.",
"identity_token_site": "Site",
"identity_token_title": "Link to a third-party site",
"level_visibility_desc": "Your level badge appears next to your name in lobbies, in game, on leaderboards, in clans and on your profile. Turn this off to hide it; it can take a little while to update everywhere. You still earn XP and see your own level.",
"level_visibility_failed": "Couldn't update your level setting. Please try again.",
"level_visibility_hidden": "Level hidden",
"level_visibility_others_see": "Others see",
"level_visibility_title": "Show my level to other players",
"link_discord": "Link Discord Account",
"link_existing_account": "Link an existing account",
"link_google": "Link Google Account",
Expand Down Expand Up @@ -82,6 +87,7 @@
"no_games": "No games played yet.",
"no_stats": "No stats available yet. Play some games to start tracking.",
"or": "OR",
"privacy_title": "Privacy",
"reactivate_subscription": "Reactivate",
"recovery_email_sent": "Recovery email sent to {email}",
"reward_daily": "Daily subscription reward",
Expand All @@ -95,6 +101,9 @@
"reward_level_up": "Level-up reward",
"reward_prestige": "Prestige reward",
"reward_signup_bonus": "Subscription signup bonus",
"search_visibility_desc": "Your public profile page can appear in Google and other search results. Turn this off to keep it out of search; search engines can take a while to drop a page they have already indexed. Anyone with the link can still open it.",
"search_visibility_failed": "Couldn't update your search setting. Please try again.",
"search_visibility_title": "Show my profile in search engines",
"sign_in_desc": "Sign in to save your stats and progress",
"sub_granted_from_purchase": "Included with your Steam purchase, paid for once on Steam. It isn't billed and doesn't renew, so there's nothing to manage or cancel. When it ends you keep the full game at no charge.",
"sub_granted_indefinite": "Free access granted to your account. It isn't billed, so there's nothing to manage or cancel.",
Expand Down
67 changes: 67 additions & 0 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
TribeStatsResponseSchema,
UserMeResponse,
UserMeResponseSchema,
VisibilityResponseSchema,
} from "@openfront/shared/ApiSchemas";
import {
AnalyticsRecord,
Expand Down Expand Up @@ -410,6 +411,72 @@ export async function setMarketingConsent(
}
}

export type SetVisibilityResult =
// 200: the stored setting, as the server echoes it.
| { ok: true; hidden: boolean }
// 401: the session is gone; logOut() has already run.
| { ok: false; code: "logged_out" }
// Anything else (400, 429, 5xx, network, unreadable body): nothing to act on
// beyond "try again".
| { ok: false; code: "failed" };

// PUT /users/@me/<setting> { hidden }: the one write path behind every privacy
// toggle, so each setter differs only in its endpoint. Idempotent: the body is
// the desired state. Invalidates the cached /users/@me on success so the next
// read reflects it.
async function putVisibility(
setting: "level_visibility" | "search_visibility",
hidden: boolean,
): Promise<SetVisibilityResult> {
try {
const response = await fetch(`${getApiBase()}/users/@me/${setting}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({ hidden }),
});
if (response.status === 401) {
await logOut();
return { ok: false, code: "logged_out" };
}
if (!response.ok) {
console.error(
`PUT ${setting}: request failed`,
response.status,
response.statusText,
);
return { ok: false, code: "failed" };
}
const parsed = VisibilityResponseSchema.safeParse(await response.json());
if (!parsed.success) {
console.error(`PUT ${setting}: Zod validation failed`, parsed.error);
return { ok: false, code: "failed" };
}
invalidateUserMe();
return { ok: true, hidden: parsed.data.hidden };
} catch (e) {
console.error(`PUT ${setting}: request failed`, e);
return { ok: false, code: "failed" };
}
}

// "Hide my level".
export function setLevelVisibility(
hidden: boolean,
): Promise<SetVisibilityResult> {
return putVisibility("level_visibility", hidden);
}

// "Keep my profile out of search engines" (the public profile page is served
// noindex).
export function setSearchVisibility(
hidden: boolean,
): Promise<SetVisibilityResult> {
return putVisibility("search_visibility", hidden);
}

// The sites a player can generate an identity token for. Fails closed: any
// error lands on an empty list, which hides the account-settings card.
export async function getIdentityTokenAudiences(): Promise<string[]> {
Expand Down
6 changes: 5 additions & 1 deletion src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,11 @@ export function joinLobby(
clientID = message.myClientID;
eventBus.emit(new LobbyInfoEvent(message.lobby, message.myClientID));
// Kept past game start for the in-game level badges.
rememberLobbyRoster(message.lobby.gameID, message.lobby.clients);
rememberLobbyRoster(
message.lobby.gameID,
message.lobby.clients,
message.myClientID,
);
// Preload the map while still in the lobby so game start can reuse the
// cached result instead of blocking on the download in the short
// prestart->start window. The preload is debounced: it only fires once
Expand Down
24 changes: 22 additions & 2 deletions src/client/LobbyRosterLevels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ClientID, GameID } from "@openfront/engine-api/Schemas";
import { LevelBadge, unpackLevelBadge } from "@openfront/shared/LevelBadgeWire";
import { ClientInfo } from "@openfront/shared/WireSchemas";
import {
ownHiddenLevelBadge,
refreshOwnHiddenLevelBadge,
} from "./OwnLevelBadge";

// Level badges from the last lobby roster this tab was sent, for in-game UI.
// The start message carries no levels, so a tab that never saw the lobby (a
Expand All @@ -9,26 +13,42 @@ import { ClientInfo } from "@openfront/shared/WireSchemas";

let rosterGameID: GameID | null = null;
let badges = new Map<ClientID, number>();
// The viewer's own clientID when they were on that roster.
let rosterSelf: ClientID | null = null;

export function rememberLobbyRoster(
gameID: GameID,
clients: readonly ClientInfo[] | undefined,
myClientID?: ClientID,
): void {
const next = new Map<ClientID, number>();
let self: ClientID | null = null;
for (const c of clients ?? []) {
if (c.levelBadge !== undefined) next.set(c.clientID, c.levelBadge);
if (c.clientID === myClientID) self = myClientID;
}
if (gameID !== rosterGameID) {
// Once per game: the viewer's own badge if they hide their level (their
// roster entry carries none). From the memoised /users/@me.
void refreshOwnHiddenLevelBadge();
}
rosterGameID = gameID;
badges = next;
rosterSelf = self;
}

// The player's badge in game `gameID`, or undefined when they have none (a
// guest, an anonymized name, progression off, their level hidden, a value out
// of range) or this tab never saw them in that game's lobby.
// of range) or this tab never saw them in that game's lobby. The viewer's own
// badge still shows while they hide their level: it comes from their own
// /users/@me then.
export function lobbyLevelBadge(
gameID: GameID,
clientID: ClientID | null,
): LevelBadge | undefined {
if (clientID === null || gameID !== rosterGameID) return undefined;
return unpackLevelBadge(badges.get(clientID));
return (
unpackLevelBadge(badges.get(clientID)) ??
(clientID === rosterSelf ? ownHiddenLevelBadge() : undefined)
);
}
52 changes: 52 additions & 0 deletions src/client/OwnLevelBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { UserMeResponse } from "@openfront/shared/ApiSchemas";
import type { LevelBadge } from "@openfront/shared/LevelBadgeWire";
import { getUserMe } from "./Api";

// The viewer's OWN level badge while they hide their level ("hide my level").
//
// The game server leaves a hidden player's badge off the lobby roster for
// everyone, the player included, but they still see their own level. So their
// client draws it from its own /users/@me instead — local only, nothing is
// sent anywhere. Undefined while the level is shown (the roster carries the
// badge then), for guests, and when progression is off.

export function ownHiddenLevelBadgeFrom(
me: UserMeResponse | false,
): LevelBadge | undefined {
if (me === false || me.player.levelHidden !== true) return undefined;
const progress = me.player.progress;
if (progress === undefined) return undefined;
return {
level: progress.level,
prestige: progress.prestige,
legend: progress.legend,
};
}

let cached: LevelBadge | undefined;
// Bumped by every refresh and when the session is cleared: only the latest
// refresh writes the cache. One still waiting on an older /users/@me (from
// before a visibility change, or the old account's after a clear) leaves it.
let generation = 0;

// The last value refreshOwnHiddenLevelBadge() found, for synchronous readers
// (the in-game player panel).
export function ownHiddenLevelBadge(): LevelBadge | undefined {
return cached;
}

// Re-reads it from /users/@me. getUserMe() is memoised, so this is normally
// answered from the profile already loaded at sign-in / join — no request.
export async function refreshOwnHiddenLevelBadge(): Promise<
LevelBadge | undefined
> {
const forGeneration = ++generation;
const badge = ownHiddenLevelBadgeFrom(await getUserMe());
if (forGeneration === generation) cached = badge;
return cached;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

document.addEventListener("session-cleared", () => {
generation++;
cached = undefined;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
17 changes: 12 additions & 5 deletions src/client/PlayerProfileModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { fetchPublicPlayerProfile } from "./Api";
import { fetchPublicPlayerProfile, getUserMe } from "./Api";
import "./ClanModal";
import "./components/baseComponents/stats/PlayerGameHistoryView";
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
Expand Down Expand Up @@ -239,13 +239,20 @@ export class PlayerProfileModal extends BaseModal {
`;
}

// Everyone's level, your own included, comes from the public endpoint: the
// /users/@me copy is cached from page load and would miss the games played
// since. Missing progress hides the summary.
// Everyone's level, your own included, comes from the public endpoint first:
// the /users/@me copy is cached from page load and would miss the games
// played since. But that endpoint 404s for a player who hides their level —
// for everyone, them included — and they still see their own level. So on
// your OWN profile only, no public progress falls back to your /users/@me
// copy (memoised: normally no request). Nobody else's profile ever does, so a
// hidden level stays hidden from others. Missing progress hides the card.
private async loadProgress(publicId: string): Promise<PublicProgress | null> {
try {
const progress = await fetchPublicPlayerProgress(publicId);
return progress === false ? null : progress;
if (progress !== false) return progress;
const me = await getUserMe();
if (me === false || me.player.publicId !== publicId) return null;
return me.player.progress ?? null;
} catch {
return null;
}
Expand Down
Loading
Loading