mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 01:22:30 +00:00
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:
@@ -15,6 +15,7 @@
|
||||
"nukeRed": 7,
|
||||
"nukeWhite": 8,
|
||||
"allianceFaded": 9,
|
||||
"doomsdayClock": 10
|
||||
"doomsdayClock": 10,
|
||||
"verified": 11
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 164 KiB |
@@ -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",
|
||||
|
||||
@@ -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 | "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -442,6 +442,7 @@ export class WebGLFrameBuilder {
|
||||
displayName: p.displayName(),
|
||||
flag: flagUrl,
|
||||
crown: crownUrl,
|
||||
verified: p.cosmetics.verified === true,
|
||||
color: p.territoryColor().toHex(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
+45
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user