feat(client): verified-name toggle (play under your account name) (#4648)

## Summary

The client half of the verified-name plan: subscribers with a claimed
bare name can opt in to play under it, and the game renders a
**server-validated** blue verified check next to their name.

### Verified toggle (username row)
- Blue check-circle badge + "Verified" label act as a toggle button in
the play username row, shown to all users (hidden on CrazyGames via
`no-crazygames`); both turn blue when active and the input locks to the
bare account name — `getUsername()` feeds every join path.
- Non-subscribers (logged out, `unclaimed`, lapsed `claimed`) get a
subscribe-first dialog whose **View store** routes to
`#modal=store&tab=subscriptions`. Entitled players without a usable name
(never set, or `TEMPORARY####`) are routed to the account modal instead.
- The opt-in persists in localStorage but never auto-enables while
ineligible; unchecking restores the saved free-form name. Anonymity
stays a first-class option.
- After a successful username save the page reloads so every consumer
restarts from a fresh `/users/@me`.

### In-game badge (GL name pass)
- New `verified` boolean on `PlayerCosmeticRefsSchema` (client claim)
and `PlayerCosmeticsSchema` (resolved). `getPlayerCosmeticsRefs()` sets
it from the toggle state, covering both the multiplayer join and
locally-resolved singleplayer paths.
- **Server-validated at join, today**: the Worker already fetches
`/users/@me` with the client's token on every authenticated join
(flares/friends/clans), and that response carries the account username
since #4644 — so `verifiedBadgeAllowed` keeps the claim only when the
bare-name status is `premium`/`indefinite` AND the join name exactly
matches the account's resolved display name. Zero extra requests, no
token-claim staleness. Mismatches strip the badge rather than rejecting
the join; a pre-start rejoin identity change also drops it (that path
skips join-time validation). Anonymous persistent-ID joins exist only in
Dev and keep the claim for local testing.
- Rendering: 10th instanced slot in the name pass's `StatusIconProgram`,
anchored just right of the name text (`nameHalfWidth` was already in the
player data texture), slightly below the name line's center. The badge
art is a new cell (index 11) in `status-atlas.png`; the flag rides the
free `pd8.y` column. Anonymized viewers never see it (cosmetics are
already stripped for hidden players).

## Test plan

- Full suite passes (2,047 + 173), including new tests: cosmetics schema
`verified` (optional/boolean-only), `Privilege.isAllowed` pass-through,
and `verifiedBadgeAllowed` (exact match, case rejection, unentitled
statuses, missing name).
- Headless real-app verification of every toggle state (dialogs,
persistence, silent drops, store-tab routing, save→reload) with stubbed
API routes.
- Drove a real singleplayer game headless (WebGL via ANGLE Metal): the
blue check renders to the right of "Bob", scaled and tucked to the name;
bot/nation names show no badge. Screenshot-verified.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-20 08:28:21 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent d9976babbd
commit ad8d7e995a
20 changed files with 508 additions and 80 deletions
+1 -4
View File
@@ -465,10 +465,7 @@ export class AccountModal extends BaseModal {
private renderUsernamePanel(): TemplateResult | "" {
const player = this.userMeResponse?.player;
if (!player || player.usernameStatus === undefined) return "";
return html`<username-panel
.player=${player}
@username-changed=${() => this.requestUpdate()}
></username-panel>`;
return html`<username-panel .player=${player}></username-panel>`;
}
private renderRewardsPanel(): TemplateResult | "" {
+6
View File
@@ -30,6 +30,7 @@ import {
purchaseWithCurrency,
} from "./Api";
import { showInGameAlert, showInGameConfirm } from "./InGameModal";
import { isPlayingVerified } from "./UsernameInput";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
@@ -759,6 +760,7 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
skinName,
crownName,
effects: Object.keys(effects).length > 0 ? effects : undefined,
verified: isPlayingVerified() ? true : undefined,
};
}
@@ -823,6 +825,10 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
if (Object.keys(effects).length > 0) result.effects = effects;
}
if (refs.verified) {
result.verified = true;
}
return result;
}
+146 -3
View File
@@ -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 {
</div>
<input
type="text"
.value=${this.baseUsername}
.value=${this.verifiedActive
? (this.verifiedName() ?? "")
: this.baseUsername}
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
?disabled=${this.verifiedActive}
title=${this.verifiedActive
? translateText("username.verified_heading")
: ""}
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent disabled:text-blue-400 disabled:cursor-not-allowed"
/>
<button
type="button"
class="no-crazygames group flex items-center gap-1.5 shrink-0 cursor-pointer select-none"
title=${translateText("username.verified_heading")}
aria-pressed=${this.verifiedActive ? "true" : "false"}
@click=${this.handleVerifiedToggle}
>
<svg
viewBox="0 0 24 24"
class="w-5 h-5 transition-colors ${this.verifiedActive
? "text-blue-400"
: "text-white/30 group-hover:text-white/50"}"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
<path
d="M7.5 12.5l3 3 6-6.5"
stroke="white"
stroke-width="2.2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span
class="hidden sm:inline text-sm font-medium transition-colors ${this
.verifiedActive
? "text-blue-400"
: "text-white/70 group-hover:text-white"}"
>${translateText("username.verified_toggle")}</span
>
</button>
</div>
${this.validationError
? html`<div
@@ -286,6 +409,19 @@ export class UsernameInput extends LitElement {
return;
}
// Playing under the verified account name: it's server-issued, so skip
// free-form validation and leave the stored free-form name untouched for
// when the toggle turns off.
if (this.verifiedActive) {
this._isValid = true;
this.validationError = "";
if (!this.onCrazyGames) {
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
}
this.emitValidity();
return;
}
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
@@ -319,6 +455,13 @@ export class UsernameInput extends LitElement {
}
}
// Whether the player is currently playing under their verified account name.
// For join paths that can't reach the component instance (Cosmetics refs).
export function isPlayingVerified(): boolean {
const el = document.querySelector<UsernameInput>("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
+1
View File
@@ -442,6 +442,7 @@ export class WebGLFrameBuilder {
displayName: p.displayName(),
flag: flagUrl,
crown: crownUrl,
verified: p.cosmetics.verified === true,
color: p.territoryColor().toHex(),
});
}
+31 -3
View File
@@ -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)}
</div>`;
},
)}
@@ -196,7 +196,9 @@ export class LobbyTeamView extends LitElement {
? "current-player"
: ""}"
>
<span class="text-white">${displayName}</span>
<span class="text-white"
>${displayName} ${this.renderVerifiedBadge(client)}</span
>
${this.renderRevealToggle(client.clientID)}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
@@ -266,7 +268,9 @@ export class LobbyTeamView extends LitElement {
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
<span class="truncate text-white"
>${displayName} ${this.renderVerifiedBadge(p)}</span
>
${this.renderRevealToggle(p.clientID)}
${p.clientID === this.lobbyCreatorClientID
? html`<span class="ml-2 text-[11px] text-green-300"
@@ -424,4 +428,28 @@ export class LobbyTeamView extends LitElement {
createRandomName(client.username, PlayerType.Human) ?? client.username;
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
}
// Blue check for players on their server-validated account name. Withheld
// when this row's name is locally anonymized — the badge vouches for the
// exact displayed name.
private renderVerifiedBadge(client: ClientInfo) {
const anonymized =
this.userSettings.anonymousNames() && !this.isCurrentPlayer(client);
if (client.verified !== true || anonymized) return html``;
return html`<svg
viewBox="0 0 24 24"
class="inline-block w-3.5 h-3.5 align-[-2px] text-blue-400 shrink-0"
aria-label=${translateText("username.verified_heading")}
>
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
<path
d="M7.5 12.5l3 3 6-6.5"
stroke="white"
stroke-width="2.2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>`;
}
}
+7 -35
View File
@@ -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(
@@ -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;
@@ -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;
@@ -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;
+2
View File
@@ -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;
}