diff --git a/resources/atlases/status-atlas-meta.json b/resources/atlases/status-atlas-meta.json index 9f93da03c..f78bcbc94 100644 --- a/resources/atlases/status-atlas-meta.json +++ b/resources/atlases/status-atlas-meta.json @@ -15,6 +15,7 @@ "nukeRed": 7, "nukeWhite": 8, "allianceFaded": 9, - "doomsdayClock": 10 + "doomsdayClock": 10, + "verified": 11 } } diff --git a/resources/atlases/status-atlas.png b/resources/atlases/status-atlas.png index 67bc65db9..f02aa86da 100644 Binary files a/resources/atlases/status-atlas.png and b/resources/atlases/status-atlas.png differ diff --git a/resources/lang/en.json b/resources/lang/en.json index fc826bd00..29cb3b0d5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -67,7 +67,6 @@ "tab_stats": "Stats", "title": "Account", "unclaimed_rewards": "Unclaimed Rewards", - "username_changed": "Username changed to {name}.", "username_confirm_abandon": "You will permanently give up your reserved name \"{name}\".", "username_confirm_body": "Your new name will be locked for 30 days.", "username_confirm_button": "Change name", @@ -1623,7 +1622,11 @@ "tag_too_long": "Clan tag cannot exceed 5 characters.", "tag_too_short": "Clan tag must be 2-5 alphanumeric characters.", "too_long": "Username must not exceed {max} characters.", - "too_short": "Username must be at least {min} characters long." + "too_short": "Username must be at least {min} characters long.", + "verified_heading": "Verified name", + "verified_sub_required": "You need an active subscription to play under your verified name.", + "verified_sub_required_confirm": "View store", + "verified_toggle": "Verified" }, "win_modal": { "died": "You died", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index c62b34505..d23ddab08 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -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` this.requestUpdate()} - >`; + return html``; } private renderRewardsPanel(): TemplateResult | "" { diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 40fcb90e3..766c9279a 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -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 { skinName, crownName, effects: Object.keys(effects).length > 0 ? effects : undefined, + verified: isPlayingVerified() ? true : undefined, }; } @@ -823,6 +825,10 @@ export async function getPlayerCosmetics(): Promise { 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(() => {