{
if (Object.keys(effects).length > 0) result.effects = effects;
}
+ if (refs.verified) {
+ result.verified = true;
+ }
+
return result;
}
diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts
index 2106624e4..18f2bbf0c 100644
--- a/src/client/UsernameInput.ts
+++ b/src/client/UsernameInput.ts
@@ -1,7 +1,8 @@
-import { LitElement, html } from "lit";
+import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { ANON_ANIMALS, anonAnimalName } from "../core/AnonAnimals";
+import { isTemporaryUsername, UserMeResponse } from "../core/ApiSchemas";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
@@ -13,6 +14,7 @@ import {
} from "../core/validations/username";
import { checkClanTagOwnership } from "./ClanApi";
import { crazyGamesSDK } from "./CrazyGamesSDK";
+import { showInGameConfirm } from "./InGameModal";
interface LangSelectorLike {
currentLang?: string;
@@ -22,11 +24,16 @@ interface LangSelectorLike {
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
+const useVerifiedNameKey: string = "useVerifiedName";
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private baseUsername: string = "";
@state() private clanTag: string = "";
+ // Playing under the account's verified bare name (sub-only). The free-form
+ // name stays in baseUsername/localStorage so unchecking restores it.
+ @state() private verifiedActive: boolean = false;
+ private userMe: UserMeResponse | false | null = null;
// Clans aren't supported on CrazyGames — hide the tag input and never submit one.
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
@@ -51,10 +58,88 @@ export class UsernameInput extends LitElement {
return this;
}
+ constructor() {
+ super();
+ // Account state for the verified-name toggle. Same document-level pattern
+ // as AccountModal; Main dispatches this after auth resolves and on
+ // CrazyGames sign-in.
+ document.addEventListener("userMeResponse", (event: Event) => {
+ this.userMe = (event as CustomEvent).detail as UserMeResponse | false;
+ this.applyVerifiedPreference();
+ });
+ }
+
+ // The server-resolved bare name this player may play verified under, or null
+ // when ineligible. Sub-only by design: `claimed` (lapsed) holders and
+ // TEMPORARY####-renamed players don't qualify.
+ private verifiedName(): string | null {
+ if (this.userMe === null || this.userMe === false) return null;
+ const player = this.userMe.player;
+ const status = player.usernameStatus;
+ if (status !== "premium" && status !== "indefinite") return null;
+ if (!player.username || isTemporaryUsername(player.usernameBase)) {
+ return null;
+ }
+ return player.username;
+ }
+
+ // Turn the toggle on iff the player opted in previously AND is still
+ // eligible; silently off otherwise (logout, lapsed sub, TEMPORARY rename).
+ // Never auto-enables without a stored opt-in — players who want to stay
+ // anonymous must be able to play under an unrelated name.
+ private applyVerifiedPreference() {
+ this.verifiedActive =
+ !this.onCrazyGames &&
+ localStorage.getItem(useVerifiedNameKey) === "true" &&
+ this.verifiedName() !== null;
+ this.requestUpdate();
+ this.validateAndStore();
+ }
+
+ private async handleVerifiedToggle() {
+ // verifiedActive implies eligible (applyVerifiedPreference), so this
+ // covers both turning off and an eligible turn-on.
+ if (this.verifiedActive || this.verifiedName() !== null) {
+ this.verifiedActive = !this.verifiedActive;
+ localStorage.setItem(useVerifiedNameKey, String(this.verifiedActive));
+ this.validateAndStore();
+ return;
+ }
+ // Ineligible — the toggle can't turn on.
+ const player = this.userMe === false ? undefined : this.userMe?.player;
+ const status = player?.usernameStatus;
+ if (status === "premium" || status === "indefinite") {
+ // Subscribed but no usable name yet (never set, or TEMPORARY####):
+ // send them to the account modal to pick one.
+ window.location.hash = "modal=account";
+ return;
+ }
+ const goStore = await showInGameConfirm(
+ translateText("username.verified_sub_required"),
+ {
+ heading: translateText("username.verified_heading"),
+ variant: "warning",
+ confirmText: translateText("username.verified_sub_required_confirm"),
+ },
+ );
+ if (goStore) {
+ window.location.hash = "modal=store&tab=subscriptions";
+ }
+ }
+
public getUsername(): string {
+ if (this.verifiedActive) {
+ const verified = this.verifiedName();
+ if (verified !== null) return verified;
+ }
return this.baseUsername.trim();
}
+ /** True when the player is playing under their verified account name. */
+ public isVerified(): boolean {
+ return this.verifiedActive && this.verifiedName() !== null;
+ }
+
public getClanTag(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
@@ -172,13 +257,51 @@ export class UsernameInput extends LitElement {
+
${this.validationError
? html`("username-input");
+ return el?.isVerified() ?? false;
+}
+
// A memorable anonymous username: "Anon" + animal (+ digit), the same handle
// format the server-side anonymisation overlay uses (anonAnimalName). Client-side
// fallback for players who never set a name — no roster here, so it draws a
diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts
index 7b168602a..260c8e9fc 100644
--- a/src/client/WebGLFrameBuilder.ts
+++ b/src/client/WebGLFrameBuilder.ts
@@ -442,6 +442,7 @@ export class WebGLFrameBuilder {
displayName: p.displayName(),
flag: flagUrl,
crown: crownUrl,
+ verified: p.cosmetics.verified === true,
color: p.territoryColor().toHex(),
});
}
diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts
index a336d5a71..e8e3acedf 100644
--- a/src/client/components/LobbyPlayerView.ts
+++ b/src/client/components/LobbyPlayerView.ts
@@ -134,7 +134,7 @@ export class LobbyTeamView extends LitElement {
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
- ${displayName}
+ ${displayName} ${this.renderVerifiedBadge(client)}
`;
},
)}
@@ -196,7 +196,9 @@ export class LobbyTeamView extends LitElement {
? "current-player"
: ""}"
>
- ${displayName}
+ ${displayName} ${this.renderVerifiedBadge(client)}
${this.renderRevealToggle(client.clientID)}
${client.clientID === this.lobbyCreatorClientID
? html`
- ${displayName}
+ ${displayName} ${this.renderVerifiedBadge(p)}
${this.renderRevealToggle(p.clientID)}
${p.clientID === this.lobbyCreatorClientID
? html`
+
+
+ `;
+ }
}
diff --git a/src/client/components/UsernamePanel.ts b/src/client/components/UsernamePanel.ts
index 49b66912e..7aa49de28 100644
--- a/src/client/components/UsernamePanel.ts
+++ b/src/client/components/UsernamePanel.ts
@@ -115,44 +115,16 @@ export class UsernamePanel extends LitElement {
this.busy = true;
const result = await updateUsername(name);
- this.busy = false;
if (result.ok) {
- // The panel and AccountModal share the same player object, so updating
- // it here keeps every consumer consistent; the event just triggers the
- // parent re-render.
- this.player.username = result.data.username;
- this.player.usernameBase = result.data.base;
- this.player.usernameDiscriminator = result.data.discriminator;
- this.player.usernameStatus = result.data.usernameStatus;
- this.player.nextUsernameChangeAt = result.data.nextUsernameChangeAt;
- // A rename either kept the claim (premium/indefinite) or abandoned it
- // (unclaimed) — either way no grace deadline remains.
- this.player.usernameClaimExpiresAt = null;
- this.draft = result.data.base;
- this.error = "";
- this.requestUpdate();
- window.dispatchEvent(
- new CustomEvent("show-message", {
- detail: {
- message: translateText("account_modal.username_changed", {
- name: result.data.username,
- }),
- color: "green",
- duration: 4000,
- },
- }),
- );
- this.dispatchEvent(
- new CustomEvent("username-changed", {
- detail: result.data,
- bubbles: true,
- composed: true,
- }),
- );
- } else {
- this.error = this.errorMessage(result);
+ // Reload so every consumer starts from a fresh /users/@me; the account
+ // modal reopens via #modal=account showing the new name. Keep the form
+ // locked (busy) while the reload happens.
+ window.location.reload();
+ return;
}
+ this.busy = false;
+ this.error = this.errorMessage(result);
}
private errorMessage(
diff --git a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts
index b09c3a4cf..07bf494c3 100644
--- a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts
+++ b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts
@@ -1,9 +1,10 @@
/**
* StatusIconProgram — instanced status icons above player names.
*
- * Renders up to 9 status icons per player (crown, traitor, disconnected,
- * alliance, alliance request, target, embargo, nuke, doomsday-clock skull). Each
- * instance reads individual float flags to decide whether to draw.
+ * Renders up to 10 icons per player: 9 status-row icons above the name
+ * (crown, traitor, disconnected, alliance, alliance request, target, embargo,
+ * nuke, doomsday-clock skull) plus the verified badge to the right of the
+ * name. Each instance reads individual float flags to decide whether to draw.
*
* Owns: shader program, uniform locations, status atlas texture.
* The shared playerDataTex is passed in but not owned/deleted.
@@ -20,7 +21,7 @@ import type { ParsedAtlas } from "./Types";
const statusAtlasUrl = assetUrl("atlases/status-atlas.png");
-const MAX_STATUS_ICONS = 9;
+const MAX_STATUS_ICONS = 10;
export class StatusIconProgram {
private gl: WebGL2RenderingContext;
diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts
index 8de80d6ad..f6201a731 100644
--- a/src/client/render/gl/passes/name-pass/index.ts
+++ b/src/client/render/gl/passes/name-pass/index.ts
@@ -667,9 +667,9 @@ export class NamePass {
d[off + 30] = slot.allianceFraction;
d[off + 31] = slot.allianceRemainingTicks;
- // Column 8: crownLayerIdx (crown cosmetic), rest free
+ // Column 8: crownLayerIdx (crown cosmetic), verified badge, rest free
d[off + 32] = slot.crownLayerIdx;
- d[off + 33] = 0.0;
+ d[off + 33] = slot.static.verified === true ? 1.0 : 0.0;
d[off + 34] = 0.0;
d[off + 35] = 0.0;
diff --git a/src/client/render/gl/shaders/name/status-icon.vert.glsl b/src/client/render/gl/shaders/name/status-icon.vert.glsl
index 214d10a16..a9f0d5a1f 100644
--- a/src/client/render/gl/shaders/name/status-icon.vert.glsl
+++ b/src/client/render/gl/shaders/name/status-icon.vert.glsl
@@ -88,16 +88,19 @@ vec4 cellUV(int idx) {
}
void main() {
- // Decode instance ID → playerIdx + iconSlot (0..8)
- int playerIdx = gl_InstanceID / 9;
- int iconSlot = gl_InstanceID - playerIdx * 9;
+ // Decode instance ID → playerIdx + iconSlot (0..8 status row, 9 = verified
+ // badge to the right of the name)
+ int playerIdx = gl_InstanceID / 10;
+ int iconSlot = gl_InstanceID - playerIdx * 10;
+ bool isVerifiedSlot = (iconSlot == 9);
// Read player data
vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime
vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive
+ vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, nameShade, nameHalfWidth
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, smallID, [free]
vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks
- vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, [free]
+ vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, verified, [free]
// A crown cosmetic skins the first-place crown (slot 0).
vCrownLayer = (iconSlot == 0 && pd8.x >= 0.0) ? int(pd8.x) : -1;
@@ -120,8 +123,10 @@ void main() {
// Read status flags into array
readStatusFlags(playerIdx);
- // Early out: this icon slot is inactive
- if (statusFlag[iconSlot] < 0.5) {
+ // Early out: this icon slot is inactive. The verified badge (slot 9) is
+ // driven by pd8.y, not the status-row flag array.
+ bool slotActive = isVerifiedSlot ? (pd8.y > 0.5) : (statusFlag[iconSlot] > 0.5);
+ if (!slotActive) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vLocalUV = vec2(0.0);
@@ -168,21 +173,31 @@ void main() {
// Icon world size: matches name text height
float iconWorldSize = uFontBase * nameWorldScale * 1.1;
- // Count active icons and position of this one (left-to-right)
- int totalActive = 0;
- for (int i = 0; i < 9; i++) {
- if (statusFlag[i] > 0.5) totalActive++;
+ float iconX;
+ float iconY;
+ if (isVerifiedSlot) {
+ // Verified badge: anchored just right of the name text, sitting slightly
+ // below the name line's vertical center (name glyphs center on wy).
+ iconWorldSize = uFontBase * nameWorldScale * 0.9;
+ iconX = wx + pd3.w * nameWorldScale + iconWorldSize * 0.12;
+ iconY = wy - iconWorldSize * 0.4;
+ } else {
+ // Count active icons and position of this one (left-to-right)
+ int totalActive = 0;
+ for (int i = 0; i < 9; i++) {
+ if (statusFlag[i] > 0.5) totalActive++;
+ }
+ int myIndex = countBelow(iconSlot);
+
+ // Horizontal centering: spread icons evenly above the name
+ float gap = iconWorldSize * 0.15;
+ float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
+ float startX = wx - totalWidth * 0.5;
+ iconX = startX + float(myIndex) * (iconWorldSize + gap);
+
+ // Position: row above the emoji row
+ iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
}
- int myIndex = countBelow(iconSlot);
-
- // Horizontal centering: spread icons evenly above the name
- float gap = iconWorldSize * 0.15;
- float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
- float startX = wx - totalWidth * 0.5;
- float iconX = startX + float(myIndex) * (iconWorldSize + gap);
-
- // Position: row above the emoji row
- float iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
// Determine atlas index
// Slots 0-6 map directly to atlas indices 0-6
@@ -194,6 +209,9 @@ void main() {
if (iconSlot == 8) {
atlasIdx = 10; // doomsday-clock skull
}
+ if (isVerifiedSlot) {
+ atlasIdx = 11; // blue verified check
+ }
// Only the alliance icon (slot 3) gets the dark outline.
vOutline = (iconSlot == 3) ? 1.0 : 0.0;
diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts
index b22cb7c39..12f5c3534 100644
--- a/src/client/render/types/Renderer.ts
+++ b/src/client/render/types/Renderer.ts
@@ -28,6 +28,8 @@ export interface PlayerStatic {
flag?: string;
/** Resolved crown-cosmetic image URL, or undefined for no crown. */
crown?: string;
+ /** Plays under the verified account username — blue check next to the name. */
+ verified?: boolean;
/** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */
color?: string;
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 0bbb7e7b9..f47dfa196 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -200,6 +200,10 @@ const ClientInfoSchema = z.object({
username: UsernameSchema,
clanTag: ClanTagSchema,
friends: z.array(z.string()).optional(),
+ // Plays under their server-validated account name (blue check in the
+ // lobby list). Never set on anonymized entries — the badge vouches for
+ // the exact display name.
+ verified: z.boolean().optional(),
});
export const GameInfoSchema = z.object({
@@ -273,6 +277,9 @@ export interface ClientInfo {
username: string;
clanTag: string | null;
friends?: ClientID[];
+ // Plays under their server-validated account name (blue check). Never set
+ // on anonymized entries.
+ verified?: boolean;
}
export enum LogSeverity {
Debug = "DEBUG",
@@ -658,6 +665,11 @@ export const PlayerCosmeticRefsSchema = z.object({
// One selected effect per slot: key = slot (effectType for trails, nukeType for
// nuke explosions — see effectTypeForSlot), value = effect name.
effects: z.record(z.string(), CosmeticNameSchema).optional(),
+ // The player claims to be playing under their verified account username
+ // (renders the blue check next to the name). The game server keeps the
+ // claim only when the join name exactly matches the account's resolved
+ // display name from /users/@me (Worker join → verifiedBadgeAllowed).
+ verified: z.boolean().optional(),
});
export const PlayerSkinSchema = z.object({
@@ -689,6 +701,8 @@ export const PlayerCosmeticsSchema = z.object({
// Resolved effects keyed by slot (effectType for trails, nukeType for nuke
// explosions).
effects: z.record(z.string(), PlayerEffectSchema).optional(),
+ // Plays under the verified account username — renders the blue check.
+ verified: z.boolean().optional(),
});
export const PlayerSchema = z.object({
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 9b1df1fe3..dca022bde 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -644,6 +644,15 @@ export class GameServer {
);
this.activeClients.push(client);
if (identityUpdate && !this.hasStarted()) {
+ // The verified badge vouches for the exact join name — a pre-start
+ // identity change under it must drop the badge (the rejoin path skips
+ // the Worker's join-time validation).
+ if (
+ identityUpdate.username !== client.username &&
+ client.cosmetics?.verified
+ ) {
+ delete client.cosmetics.verified;
+ }
client.username = identityUpdate.username;
client.clanTag = identityUpdate.clanTag;
}
@@ -1240,6 +1249,7 @@ export class GameServer {
clanTag: hideClanTags ? null : (c.clanTag ?? null),
clientID: c.clientID,
friends: friendsFor(c),
+ verified: c.cosmetics?.verified,
}
: {
username: this.anonName(viewer, c.clientID),
diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts
index b40882816..ef0f033dd 100644
--- a/src/server/Privilege.ts
+++ b/src/server/Privilege.ts
@@ -278,6 +278,13 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
}
+ // Entitlement-blind pass-through: isAllowed has no user identity. The
+ // authoritative check — join name must exactly match the account's
+ // resolved display name — runs at join in Worker.ts using the /users/@me
+ // response (enforceVerifiedBadge below).
+ if (refs.verified === true) {
+ cosmetics.verified = true;
+ }
return { type: "allowed", cosmetics };
}
@@ -409,7 +416,13 @@ const defaultMatcher = createMatcher(baselineBannedWords);
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
- return { type: "allowed", cosmetics: {} };
+ // Catalog cosmetics can't be resolved without the cosmetics data, but the
+ // verified claim isn't a catalog item — pass it through; the Worker's
+ // enforceVerifiedBadge still validates it against the account at join.
+ return {
+ type: "allowed",
+ cosmetics: refs.verified === true ? { verified: true } : {},
+ };
}
censor(
@@ -429,3 +442,34 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
return { tag: censoredTag, dropped: false };
}
}
+
+/**
+ * Enforce the client-claimed verified badge on resolved cosmetics. The claim
+ * is kept only when the account vouches for it: an entitled bare-name status
+ * (premium/indefinite) AND a join name EXACTLY matching the account's
+ * server-resolved display name — the client locks the input to that form, so
+ * any drift (a rename race, a censor rewrite, a hand-crafted join message)
+ * drops the badge. Strips, never rejects.
+ *
+ * `account` is the /users/@me player the Worker already fetches for flares;
+ * null means an anonymous persistent-ID join — those only exist in Dev, where
+ * the claim is kept so the badge stays locally testable.
+ *
+ * Returns true when an unvouched claim was stripped (for logging).
+ */
+export function enforceVerifiedBadge(
+ cosmetics: PlayerCosmetics,
+ joinUsername: string,
+ account: { username?: string | null; usernameStatus?: string } | null,
+): boolean {
+ if (cosmetics.verified !== true) return false;
+ const vouched =
+ account === null ||
+ ((account.usernameStatus === "premium" ||
+ account.usernameStatus === "indefinite") &&
+ typeof account.username === "string" &&
+ account.username === joinUsername);
+ if (vouched) return false;
+ delete cosmetics.verified;
+ return true;
+}
diff --git a/src/server/Worker.ts b/src/server/Worker.ts
index cc23c9d24..8e7c4e7fd 100644
--- a/src/server/Worker.ts
+++ b/src/server/Worker.ts
@@ -26,6 +26,7 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute";
import type { GameServer } from "./GameServer";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
+import { enforceVerifiedBadge } from "./Privilege";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
@@ -509,6 +510,9 @@ export async function startWorker() {
let publicId: string | undefined;
let friends: string[] = [];
let ownedClanTags: string[] = [];
+ let accountUsername:
+ | { username?: string | null; usernameStatus?: string }
+ | undefined;
const allowedFlares = ServerEnv.allowedFlares();
if (claims === null) {
@@ -532,6 +536,7 @@ export async function startWorker() {
publicId = result.response.player.publicId;
friends = result.response.player.friends;
ownedClanTags = result.response.player.clans?.map((c) => c.tag) ?? [];
+ accountUsername = result.response.player;
if (allowedFlares !== undefined) {
const allowed =
@@ -575,6 +580,21 @@ export async function startWorker() {
return;
}
+ // An undefined account means an anonymous persistent-ID join (no
+ // /users/@me fetch) — enforceVerifiedBadge treats that as Dev-only.
+ if (
+ enforceVerifiedBadge(
+ cosmeticResult.cosmetics,
+ censoredUsername,
+ accountUsername ?? null,
+ )
+ ) {
+ log.info("Stripped unvouched verified-badge claim", {
+ persistentID: persistentId,
+ gameID: clientMsg.gameID,
+ });
+ }
+
// Turnstile gates the FIRST join only. An already-admitted player who
// reconnects (e.g. a socket drop during the lobby->start transition,
// after which the server has cleared their reconnection mapping) must
diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts
index 6f298ef18..e8f0d90b6 100644
--- a/tests/CosmeticSchemas.test.ts
+++ b/tests/CosmeticSchemas.test.ts
@@ -13,7 +13,11 @@ import {
SubscriptionSchema,
TrailEffectAttributesSchema,
} from "../src/core/CosmeticSchemas";
-import { PlayerEffectSchema } from "../src/core/Schemas";
+import {
+ PlayerCosmeticRefsSchema,
+ PlayerCosmeticsSchema,
+ PlayerEffectSchema,
+} from "../src/core/Schemas";
describe("Effect cosmetic schemas", () => {
const base = {
@@ -964,3 +968,35 @@ describe("SubscriptionSchema canCreatePublicLobbies", () => {
).toBe(false);
});
});
+
+describe("verified badge on cosmetics schemas", () => {
+ it("accepts a verified claim on refs and resolved cosmetics", () => {
+ const refs = PlayerCosmeticRefsSchema.safeParse({ verified: true });
+ expect(refs.success).toBe(true);
+ if (refs.success) {
+ expect(refs.data.verified).toBe(true);
+ }
+ const resolved = PlayerCosmeticsSchema.safeParse({ verified: true });
+ expect(resolved.success).toBe(true);
+ if (resolved.success) {
+ expect(resolved.data.verified).toBe(true);
+ }
+ });
+
+ it("stays optional (old clients omit it)", () => {
+ const refs = PlayerCosmeticRefsSchema.safeParse({});
+ expect(refs.success).toBe(true);
+ if (refs.success) {
+ expect(refs.data.verified).toBeUndefined();
+ }
+ });
+
+ it("rejects a non-boolean verified", () => {
+ expect(
+ PlayerCosmeticRefsSchema.safeParse({ verified: "yes" }).success,
+ ).toBe(false);
+ expect(PlayerCosmeticsSchema.safeParse({ verified: 1 }).success).toBe(
+ false,
+ );
+ });
+});
diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts
index 47615d2bc..ebd133494 100644
--- a/tests/Privilege.test.ts
+++ b/tests/Privilege.test.ts
@@ -1,5 +1,6 @@
import {
createMatcher,
+ enforceVerifiedBadge,
FailOpenPrivilegeChecker,
PrivilegeCheckerImpl,
shadowNames,
@@ -496,6 +497,104 @@ describe("Flag validation in isAllowed", () => {
});
});
+describe("Verified badge in isAllowed", () => {
+ test("passes through a verified claim", () => {
+ const result = flagChecker.isAllowed([], { verified: true });
+ expect(result.type).toBe("allowed");
+ if (result.type === "allowed") {
+ expect(result.cosmetics.verified).toBe(true);
+ }
+ });
+
+ test("stays unset when absent or false", () => {
+ for (const refs of [{}, { verified: false }]) {
+ const result = flagChecker.isAllowed([], refs);
+ expect(result.type).toBe("allowed");
+ if (result.type === "allowed") {
+ expect(result.cosmetics.verified).toBeUndefined();
+ }
+ }
+ });
+});
+
+describe("enforceVerifiedBadge", () => {
+ test("keeps the badge for an entitled player joining under their exact bare name", () => {
+ for (const usernameStatus of ["premium", "indefinite"]) {
+ const cosmetics = { verified: true };
+ expect(
+ enforceVerifiedBadge(cosmetics, "Bob", {
+ username: "Bob",
+ usernameStatus,
+ }),
+ ).toBe(false);
+ expect(cosmetics.verified).toBe(true);
+ }
+ });
+
+ test("strips on a case-different join name (exact match only)", () => {
+ const cosmetics = { verified: true };
+ expect(
+ enforceVerifiedBadge(cosmetics, "bob", {
+ username: "Bob",
+ usernameStatus: "premium",
+ }),
+ ).toBe(true);
+ expect(cosmetics.verified).toBeUndefined();
+ });
+
+ test("strips on a different name entirely", () => {
+ const cosmetics = { verified: true };
+ expect(
+ enforceVerifiedBadge(cosmetics, "Alice", {
+ username: "Bob",
+ usernameStatus: "premium",
+ }),
+ ).toBe(true);
+ expect(cosmetics.verified).toBeUndefined();
+ });
+
+ test("strips unentitled statuses even on an exact match", () => {
+ for (const usernameStatus of ["unclaimed", "claimed", undefined]) {
+ const cosmetics = { verified: true };
+ expect(
+ enforceVerifiedBadge(cosmetics, "Bob.4821", {
+ username: "Bob.4821",
+ usernameStatus,
+ }),
+ ).toBe(true);
+ expect(cosmetics.verified).toBeUndefined();
+ }
+ });
+
+ test("strips when the account has no username set", () => {
+ for (const account of [
+ { username: null, usernameStatus: "premium" },
+ { usernameStatus: "premium" },
+ ]) {
+ const cosmetics = { verified: true };
+ expect(enforceVerifiedBadge(cosmetics, "Bob", account)).toBe(true);
+ expect(cosmetics.verified).toBeUndefined();
+ }
+ });
+
+ test("keeps the badge on an anonymous join (null account, Dev-only)", () => {
+ const cosmetics = { verified: true };
+ expect(enforceVerifiedBadge(cosmetics, "Whatever", null)).toBe(false);
+ expect(cosmetics.verified).toBe(true);
+ });
+
+ test("no-op without a claim", () => {
+ for (const cosmetics of [{}, { verified: false }]) {
+ expect(
+ enforceVerifiedBadge(cosmetics, "Bob", {
+ username: "Other",
+ usernameStatus: "unclaimed",
+ }),
+ ).toBe(false);
+ }
+ });
+});
+
describe("Skin validation", () => {
describe("isSkinAllowed (direct)", () => {
test("returns skin when user has wildcard flare", () => {
diff --git a/tests/server/AnonymizeNames.test.ts b/tests/server/AnonymizeNames.test.ts
index 0b109080d..08d947c93 100644
--- a/tests/server/AnonymizeNames.test.ts
+++ b/tests/server/AnonymizeNames.test.ts
@@ -21,6 +21,7 @@ function makeClient(
role: string | null = null,
publicId: string | undefined = undefined,
friends: string[] = [],
+ cosmetics: { verified?: boolean } | undefined = undefined,
): Client {
return new Client(
clientID,
@@ -32,7 +33,7 @@ function makeClient(
username,
clanTag,
makeMockWs() as any,
- undefined,
+ cosmetics,
publicId,
friends,
);
@@ -67,9 +68,17 @@ function makeGame(
[
makeClient("creator", "creator-pid", "CreatorReal", "HOST"),
makeClient("admin", "admin-pid", "AdminReal", "ADM", "admin"),
- makeClient("alice", "alice-pid", "AliceReal", "AAA", null, "alice-pub", [
- "bob-pub",
- ]),
+ makeClient(
+ "alice",
+ "alice-pid",
+ "AliceReal",
+ "AAA",
+ null,
+ "alice-pub",
+ ["bob-pub"],
+ // Join-time validated cosmetics (enforceVerifiedBadge already ran).
+ { verified: true },
+ ),
makeClient("bob", "bob-pid", "BobReal", "BBB", null, "bob-pub"),
].forEach((c) => game.joinClient(c));
return game;
@@ -155,6 +164,30 @@ describe("anonymizeNames: gameInfo (lobby / HTTP / preview)", () => {
});
});
+describe("verified badge in gameInfo", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ it("real entries carry verified from the join-validated cosmetics", () => {
+ const info = makeGame(false).gameInfo("bob");
+ expect(byId(info, "alice").verified).toBe(true);
+ expect(byId(info, "bob").verified).toBeUndefined();
+ });
+
+ it("anonymized entries never carry verified", () => {
+ const info = makeGame(true).gameInfo("bob");
+ expect(byId(info, "alice").verified).toBeUndefined();
+ });
+
+ it("the anonymized player still sees their own badge", () => {
+ const info = makeGame(true).gameInfo("alice");
+ expect(byId(info, "alice").verified).toBe(true);
+ });
+});
+
describe("anonymizeNames: config updates propagate", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {