From 724d6390116c1a8184ffe406ce7cc079818c612d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 2 Apr 2026 14:44:56 -0700 Subject: [PATCH 1/8] fix: catch profane substrings and clan tags in username censor --- src/server/Privilege.ts | 49 ++++++++++----- tests/Privilege.test.ts | 129 +++++++++++++++++++++++++++++++--------- 2 files changed, 135 insertions(+), 43 deletions(-) diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 22ef36b5a..26fefc0da 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -6,7 +6,6 @@ import { pattern, resolveConfusablesTransformer, resolveLeetSpeakTransformer, - skipNonAlphabeticTransformer, toAsciiLowerCaseTransformer, } from "obscenity"; import countries from "resources/countries.json"; @@ -47,31 +46,50 @@ export const shadowNames = [ "AlmostPottyTrained", ]; -export function createMatcher(bannedWords: string[]): RegExpMatcher { - const customDataset = new DataSet<{ originalWord: string }>().addAll( +function buildDataset(bannedWords: string[], dedup: boolean) { + const dataset = new DataSet<{ originalWord: string }>().addAll( englishDataset, ); - for (const word of bannedWords) { try { - customDataset.addPhrase((phrase) => - phrase.setMetadata({ originalWord: word }).addPattern(pattern`${word}`), + const w = dedup ? word.toLowerCase().replace(/(.)\1+/g, "$1") : word; + dataset.addPhrase((phrase) => + phrase.setMetadata({ originalWord: word }).addPattern(pattern`${w}`), ); } catch (e) { console.error(`Invalid banned word pattern "${word}": ${e}`); } } + return dataset.build(); +} - return new RegExpMatcher({ - ...customDataset.build(), +export function createMatcher(bannedWords: string[]): RegExpMatcher { + const baseTransformers = [ + toAsciiLowerCaseTransformer(), + resolveConfusablesTransformer(), + resolveLeetSpeakTransformer(), + ]; + // substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring + // collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler" + const substringMatcher = new RegExpMatcher({ + ...buildDataset(bannedWords, false), + blacklistMatcherTransformers: baseTransformers, + }); + const collapseMatcher = new RegExpMatcher({ + ...buildDataset(bannedWords, true), blacklistMatcherTransformers: [ - toAsciiLowerCaseTransformer(), - resolveConfusablesTransformer(), - resolveLeetSpeakTransformer(), + ...baseTransformers, collapseDuplicatesTransformer(), - skipNonAlphabeticTransformer(), ], }); + return { + hasMatch: (input: string) => + substringMatcher.hasMatch(input) || collapseMatcher.hasMatch(input), + getAllMatches: (input: string, sorted?: boolean) => [ + ...substringMatcher.getAllMatches(input, sorted), + ...collapseMatcher.getAllMatches(input, sorted), + ], + } as unknown as RegExpMatcher; } /** @@ -244,8 +262,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { } } -// Default matcher with no custom banned words (just englishDataset) -const defaultMatcher = createMatcher([]); +// Words the englishDataset misses or only catches as standalone tokens. +// These are always enforced even when the remote banned-words list is unavailable. +const baselineBannedWords = ["nigger", "nigga", "chink", "spic", "kike"]; + +const defaultMatcher = createMatcher(baselineBannedWords); export class FailOpenPrivilegeChecker implements PrivilegeChecker { isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult { diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index d28f8dbbb..3d70bcf5a 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -12,6 +12,13 @@ const bannedWords = [ "auschwitz", "whitepower", "heil", + "nigger", + "nigga", + "chink", + "spic", + "kike", + "faggot", + "retard", "chair", // Test word to verify custom banned words work ]; @@ -51,45 +58,77 @@ describe("UsernameCensor", () => { expect(matcher.hasMatch("hitler")).toBe(true); expect(matcher.hasMatch("nazi")).toBe(true); expect(matcher.hasMatch("auschwitz")).toBe(true); - }); - - test("detects custom banned words like 'chair'", () => { - expect(matcher.hasMatch("chair")).toBe(true); - expect(matcher.hasMatch("Chair")).toBe(true); - expect(matcher.hasMatch("CHAIR")).toBe(true); - expect(matcher.hasMatch("MyChairName")).toBe(true); + expect(matcher.hasMatch("nigger")).toBe(true); + expect(matcher.hasMatch("nigga")).toBe(true); + expect(matcher.hasMatch("chink")).toBe(true); + expect(matcher.hasMatch("spic")).toBe(true); + expect(matcher.hasMatch("kike")).toBe(true); + expect(matcher.hasMatch("faggot")).toBe(true); + expect(matcher.hasMatch("retard")).toBe(true); }); test("detects banned words case-insensitively", () => { expect(matcher.hasMatch("Hitler")).toBe(true); expect(matcher.hasMatch("NAZI")).toBe(true); expect(matcher.hasMatch("Adolf")).toBe(true); + expect(matcher.hasMatch("NIGGER")).toBe(true); + expect(matcher.hasMatch("Nigga")).toBe(true); + expect(matcher.hasMatch("FAGGOT")).toBe(true); + expect(matcher.hasMatch("Retard")).toBe(true); }); test("detects banned words with leet speak", () => { expect(matcher.hasMatch("h1tl3r")).toBe(true); expect(matcher.hasMatch("4d0lf")).toBe(true); expect(matcher.hasMatch("n4z1")).toBe(true); + expect(matcher.hasMatch("n1gg3r")).toBe(true); + expect(matcher.hasMatch("f4gg0t")).toBe(true); + expect(matcher.hasMatch("r3t4rd")).toBe(true); }); test("detects banned words with duplicated characters", () => { expect(matcher.hasMatch("hiiitler")).toBe(true); expect(matcher.hasMatch("naazzii")).toBe(true); + expect(matcher.hasMatch("niiiigger")).toBe(true); + expect(matcher.hasMatch("faaggot")).toBe(true); }); - test("detects banned words with accented characters", () => { + test("detects banned words with accented/confusable characters", () => { expect(matcher.hasMatch("Adölf")).toBe(true); + expect(matcher.hasMatch("nïgger")).toBe(true); }); test("detects banned words as substrings", () => { expect(matcher.hasMatch("xhitlerx")).toBe(true); expect(matcher.hasMatch("IloveNazi")).toBe(true); + // Regression: slur + suffix / prefix must be caught + expect(matcher.hasMatch("niggertesting")).toBe(true); + expect(matcher.hasMatch("testingnigger")).toBe(true); + expect(matcher.hasMatch("xnazix")).toBe(true); + expect(matcher.hasMatch("faggotry")).toBe(true); + expect(matcher.hasMatch("retarded")).toBe(true); + expect(matcher.hasMatch("MyChairName")).toBe(true); + }); + + test("detects banned words with underscores/dots/numbers mixed in", () => { + // These should NOT bypass the filter (skipNonAlphabetic was intentionally removed) + // Words separated by non-alpha chars are treated as separate tokens + expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word + expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it }); test("allows clean usernames", () => { expect(matcher.hasMatch("CoolPlayer")).toBe(false); expect(matcher.hasMatch("GameMaster")).toBe(false); expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false); + expect(matcher.hasMatch("ProGamer123")).toBe(false); + expect(matcher.hasMatch("NightOwl")).toBe(false); + expect(matcher.hasMatch("DragonSlayer")).toBe(false); + }); + + test("does not false-positive on words containing banned substrings legitimately", () => { + // "snigger" is whitelisted in englishDataset + expect(matcher.hasMatch("snigger")).toBe(false); }); }); @@ -116,32 +155,64 @@ describe("UsernameCensor", () => { expect(shadowNames).toContain(nameAfterTag); }); - test("removes profane clan tag but keeps clean username", () => { - expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); - }); + describe("clan tag censoring", () => { + test("removes profane clan tag, keeps clean username", () => { + expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[HEIL]CoolPlayer")).toBe("CoolPlayer"); + }); - test("removes clan tag with leet speak profanity", () => { - expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer"); - }); + test("removes clan tag that is a slur abbreviation", () => { + // [NIG] is caught as a standalone word by englishDataset's |nig| pattern + expect(checker.censorUsername("[NIG]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[NIGG]CoolPlayer")).toBe("CoolPlayer"); + }); - test("removes clan tag with uppercased banned word", () => { - expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer"); - }); + test("removes clan tag containing full slur (≤5 chars)", () => { + // Clan tags are capped at 5 chars — only slurs that fit are catchable this way + expect(checker.censorUsername("[NIGGA]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[CHINK]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[SPIC]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[KIKE]CoolPlayer")).toBe("CoolPlayer"); + }); - test("removes clan tag containing banned word substring", () => { - expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer"); - }); + test("removes clan tag with leet speak profanity (≤5 chars)", () => { + expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer"); + }); - test("removes profane clan tag and censors profane username", () => { - const result = checker.censorUsername("[NAZI]hitler"); - // No clan tag prefix, just a shadow name - expect(shadowNames).toContain(result); - }); + test("removes clan tag containing banned word as substring (≤5 chars)", () => { + expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer"); + expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); + }); - test("removes leet speak profane clan tag and censors leet speak username", () => { - const result = checker.censorUsername("[N4Z1]h1tl3r"); - // No clan tag prefix, just a shadow name - expect(shadowNames).toContain(result); + test("keeps clean clan tag when username is clean", () => { + expect(checker.censorUsername("[COOL]Player")).toBe("[COOL] Player"); + expect(checker.censorUsername("[PRO]Player")).toBe("[PRO] Player"); + }); + + test("keeps clean clan tag, censors profane username", () => { + const result = checker.censorUsername("[COOL]nigger"); + expect(result).toMatch(/^\[COOL\] /); + expect(shadowNames).toContain(result.replace("[COOL] ", "")); + }); + + test("removes profane clan tag and censors profane username", () => { + const result = checker.censorUsername("[NAZI]hitler"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("removes profane clan tag and censors leet speak username", () => { + const result = checker.censorUsername("[N4Z1]h1tl3r"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); + + test("removes profane clan tag with slur, censors profane username", () => { + const result = checker.censorUsername("[NIG]nigger"); + expect(shadowNames).toContain(result); + expect(result).not.toContain("["); + }); }); test("returns deterministic shadow name for same input", () => { From 2d28bfcd017cea7dbca8ca84750ec9cc21ddc218 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 6 Apr 2026 11:38:24 -0700 Subject: [PATCH 2/8] Add Rarity to cosmetic items (#3587) ## Description: https://github.com/user-attachments/assets/f2216dec-72aa-497a-89cc-169c2a40021e * Fortnite-style rarity system for cosmetics: New CosmeticContainer component applies tier-based visual styling (gradient backgrounds, glowing borders, hover effects) to flag and pattern cards across 5 rarity tiers: Common, Uncommon, Rare, Epic, and Legendary * Legendary hover effects: Scale-up animation, pulsing orange glow, shimmer sweep, rotating border sweep, corner sparkles, and screen dimming backdrop * Epic hover effects: Purple shimmer sweep glint on hover * Purchase button overhaul: Green ember particles on container hover (non-common only), 40-particle burst stream on button hover, pulsating green glow, shimmer streak animation, and loading spinner on click * Clickable cosmetic cards: Clicking anywhere on a purchasable card (not just the purchase button) triggers the purchase flow * Refactored components: ArtistInfo renamed to CosmeticInfo (now shows rarity and color palette in tooltip), * Forward-compatible rarity schema: rarity field uses .or(z.string()) so unknown backend values won't break the client ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- resources/lang/en.json | 8 +- src/client/FlagInputModal.ts | 9 +- src/client/Store.ts | 12 +- src/client/TerritoryPatternsModal.ts | 2 +- src/client/components/ArtistInfo.ts | 37 -- src/client/components/CosmeticContainer.ts | 432 +++++++++++++++++++++ src/client/components/CosmeticInfo.ts | 74 ++++ src/client/components/FlagButton.ts | 63 ++- src/client/components/PatternButton.ts | 83 ++-- src/client/components/PurchaseButton.ts | 178 ++++++++- src/core/CosmeticSchemas.ts | 20 +- tests/Privilege.test.ts | 1 + 12 files changed, 751 insertions(+), 168 deletions(-) delete mode 100644 src/client/components/ArtistInfo.ts create mode 100644 src/client/components/CosmeticContainer.ts create mode 100644 src/client/components/CosmeticInfo.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index e32ce0f9f..00aee7e70 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -920,7 +920,13 @@ "selected": "selected" }, "cosmetics": { - "artist_label": "Artist:" + "artist_label": "Artist:", + "color_label": "Color:", + "common": "Common", + "uncommon": "Uncommon", + "rare": "Rare", + "epic": "Epic", + "legendary": "Legendary" }, "flag_input": { "title": "Select Flag", diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index ff27763f8..8ad0e3856 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -40,12 +40,7 @@ export class FlagInputModal extends BaseModal { .map( ([key, flag]) => html` @@ -87,7 +82,7 @@ export class FlagInputModal extends BaseModal { return html`
${noFlag} ${cosmeticFlags} ${countryFlags}
diff --git a/src/client/Store.ts b/src/client/Store.ts index d6d469518..b241345b7 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -159,7 +159,7 @@ export class StoreModal extends BaseModal { return html`
${buttons}
@@ -179,13 +179,7 @@ export class StoreModal extends BaseModal { const selectedFlag = new UserSettings().getFlag() ?? ""; buttons.push(html` handlePurchase(flag.product!)} @@ -203,7 +197,7 @@ export class StoreModal extends BaseModal { return html`
${buttons}
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 9d9c995ed..9f8c9f6d9 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -115,7 +115,7 @@ export class TerritoryPatternsModal extends BaseModal { return html`
${buttons}
diff --git a/src/client/components/ArtistInfo.ts b/src/client/components/ArtistInfo.ts deleted file mode 100644 index 3a55856aa..000000000 --- a/src/client/components/ArtistInfo.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { html, LitElement, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { translateText } from "../Utils"; - -@customElement("artist-info") -export class ArtistInfo extends LitElement { - @property({ type: String }) - artist?: string; - - createRenderRoot() { - return this; - } - - render() { - if (!this.artist) { - return nothing; - } - - return html` -
e.stopPropagation()} - > -
- ? -
- -
- `; - } -} diff --git a/src/client/components/CosmeticContainer.ts b/src/client/components/CosmeticContainer.ts new file mode 100644 index 000000000..b6bf60bf4 --- /dev/null +++ b/src/client/components/CosmeticContainer.ts @@ -0,0 +1,432 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { Product } from "../../core/CosmeticSchemas"; +import "./PurchaseButton"; + +type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary" | string; + +interface RarityConfig { + gradient: string; + border: string; + glow: string; + hoverGlowSize: string; + nameColor: string; + legendary?: boolean; + shimmer?: boolean; + shimmerColor?: string; // rgb triplet e.g. "255,200,80" + borderSweep?: boolean; + borderSweepColor?: string; // rgb triplet e.g. "192,132,252" +} + +const rarityConfig: Record = { + common: { + gradient: "rgba(80,80,80,0.55)", + border: "rgba(255,255,255,0.15)", + glow: "rgba(255,255,255,0.5)", + hoverGlowSize: "10px", + nameColor: "rgba(255,255,255,0.7)", + }, + uncommon: { + gradient: "rgba(30,100,30,0.65)", + border: "rgba(74,222,128,0.45)", + glow: "rgba(74,222,128,0.6)", + hoverGlowSize: "12px", + nameColor: "rgba(255,255,255,1)", + }, + rare: { + gradient: "rgba(20,60,160,0.70)", + border: "rgba(96,165,250,0.50)", + glow: "rgba(96,165,250,0.7)", + hoverGlowSize: "14px", + nameColor: "rgba(255,255,255,1)", + }, + epic: { + gradient: "rgba(90,20,160,0.75)", + border: "rgba(192,132,252,0.60)", + glow: "rgba(192,132,252,0.85)", + hoverGlowSize: "14px", + nameColor: "rgba(255,255,255,1)", + shimmer: true, + shimmerColor: "192,132,252", + }, + legendary: { + gradient: "rgba(180,80,0,0.75)", + border: "rgba(251,146,60,0.65)", + glow: "rgba(251,146,60,0.95)", + hoverGlowSize: "25px", + nameColor: "rgba(255,255,255,1)", + legendary: true, + shimmer: true, + shimmerColor: "255,200,80", + borderSweep: true, + borderSweepColor: "255,200,80", + }, +}; + +const fallback = rarityConfig["common"]; + +const STYLE_ID = "cosmetic-container-styles"; +if (!document.getElementById(STYLE_ID)) { + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + @keyframes legendary-pulse { + 0% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); } + 50% { box-shadow: 0 0 25px rgba(251,146,60,0.9), 0 0 45px rgba(251,146,60,0.5); } + 100% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); } + } + @keyframes legendary-shimmer { + 0% { left: -60%; } + 100% { left: 160%; } + } + @keyframes legendary-border-sweep { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + @keyframes sparkle-twinkle-0 { + 0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); } + 40%, 60% { opacity: 1; transform: scale(1.2) rotate(20deg); } + } + @keyframes sparkle-twinkle-1 { + 0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); } + 30%, 55% { opacity: 1; transform: scale(1.1) rotate(-15deg); } + } + @keyframes sparkle-twinkle-2 { + 0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); } + 45%, 65% { opacity: 1; transform: scale(1.3) rotate(10deg); } + } + @keyframes sparkle-twinkle-3 { + 0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); } + 35%, 58% { opacity: 1; transform: scale(1.0) rotate(-20deg); } + } + .legendary-hovered { + animation: legendary-pulse 1.4s ease-in-out infinite; + } + .legendary-shimmer.active { + animation: legendary-shimmer 0.8s ease-in-out; + } + .legendary-border-sweep { + animation: legendary-border-sweep 8s linear infinite; + } + .legendary-sparkle-0 { animation: sparkle-twinkle-0 1.6s ease-in-out infinite; } + .legendary-sparkle-1 { animation: sparkle-twinkle-1 1.9s ease-in-out infinite 0.3s; } + .legendary-sparkle-2 { animation: sparkle-twinkle-2 1.7s ease-in-out infinite 0.7s; } + .legendary-sparkle-3 { animation: sparkle-twinkle-3 2.0s ease-in-out infinite 0.1s; } + @keyframes cosmetic-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + .cosmetic-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.6); + border-radius: 0.75rem; + z-index: 20; + } + .cosmetic-loading-spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(255,255,255,0.2); + border-top-color: rgb(74,222,128); + border-radius: 50%; + animation: cosmetic-spin 0.8s linear infinite; + } + `; + document.head.appendChild(style); +} + +@customElement("cosmetic-container") +export class CosmeticContainer extends LitElement { + @property({ type: String }) + rarity: Rarity = "common"; + + @property({ type: Boolean }) + selected: boolean = false; + + @property({ type: String }) + name: string = ""; + + @property({ type: Object }) + product: Product | null = null; + + @property({ type: Function }) + onPurchase?: () => void; + + private static _backdrop: HTMLDivElement | null = null; + private static _ensureBackdrop(): HTMLDivElement { + if (!CosmeticContainer._backdrop) { + const el = document.createElement("div"); + el.style.cssText = ` + pointer-events: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0); + z-index: 9; + transition: background 0.3s ease; + `; + document.body.appendChild(el); + CosmeticContainer._backdrop = el; + } + return CosmeticContainer._backdrop; + } + + private _shimmer: HTMLDivElement | null = null; + private _borderSweep: HTMLDivElement | null = null; + private _sparkles: HTMLDivElement[] = []; + private _glowColor = fallback.glow; + private _glowSize = fallback.hoverGlowSize; + private _isLegendary = false; + private _hasGlint = false; + private _hasBorderSweep = false; + private _loading = false; + private _loadingOverlay: HTMLDivElement | null = null; + + createRenderRoot() { + return this; + } + + private applyHostStyles() { + const cfg = rarityConfig[this.rarity] ?? fallback; + this._glowColor = cfg.glow; + this._glowSize = cfg.hoverGlowSize; + this._isLegendary = !!cfg.legendary; + this._hasGlint = !!cfg.shimmer; + this._hasBorderSweep = !!cfg.borderSweep; + + this.style.position = "relative"; + this.style.overflow = "hidden"; + this.style.background = `linear-gradient(to top, ${cfg.gradient} 0%, rgba(15,15,20,0.85) 100%)`; + this.style.border = `1px solid ${this.selected ? cfg.glow : cfg.border}`; + this.style.backdropFilter = "blur(8px)"; + this.style.borderRadius = "0.75rem"; + this.style.transition = + "border-color 0.2s, background 0.2s, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s"; + this.style.zIndex = "0"; + this.style.cursor = this.product ? "pointer" : ""; + + if (this.selected) { + this.style.boxShadow = `0 0 18px ${cfg.glow}`; + } else if (!this.classList.contains("legendary-hovered")) { + this.style.boxShadow = ""; + } + } + + private _ensureLegendaryElements() { + if (this._shimmer || this._borderSweep) return; + + // Shimmer sweep — epic and legendary + if (this._hasGlint) { + const shimmer = document.createElement("div"); + shimmer.className = "legendary-shimmer"; + shimmer.style.cssText = ` + pointer-events: none; + position: absolute; + top: 0; + left: -60%; + width: 40%; + height: 100%; + background: linear-gradient(90deg, transparent 0%, rgba(${(rarityConfig[this.rarity] ?? fallback).shimmerColor ?? "255,200,80"},0.45) 50%, transparent 100%); + transform: skewX(-15deg); + z-index: 10; + display: none; + `; + this.appendChild(shimmer); + this._shimmer = shimmer; + } + + if (!this._hasBorderSweep) return; + const sweepWrap = document.createElement("div"); + sweepWrap.style.cssText = ` + pointer-events: none; + position: absolute; + inset: -2px; + border-radius: 0.85rem; + z-index: -1; + overflow: hidden; + display: none; + `; + const sweepInner = document.createElement("div"); + sweepInner.className = "legendary-border-sweep"; + const sc = + (rarityConfig[this.rarity] ?? fallback).borderSweepColor ?? "255,200,80"; + sweepInner.style.cssText = ` + position: absolute; + inset: -100%; + background: conic-gradient( + from 0deg, + transparent 0deg, + rgba(${sc},0.0) 60deg, + rgba(${sc},0.9) 120deg, + rgba(${sc},1) 180deg, + rgba(${sc},0.9) 240deg, + rgba(${sc},0.0) 300deg, + transparent 360deg + ); + `; + // Inner mask to hide center, show only border ring + const sweepMask = document.createElement("div"); + sweepMask.style.cssText = ` + position: absolute; + inset: 2px; + border-radius: 0.75rem; + background: transparent; + `; + sweepWrap.appendChild(sweepInner); + sweepWrap.appendChild(sweepMask); + this.appendChild(sweepWrap); + this._borderSweep = sweepWrap; + + // Corner sparkles ✦ + const corners = [ + { top: "4px", left: "4px" }, + { top: "4px", right: "4px" }, + { bottom: "4px", left: "4px" }, + { bottom: "4px", right: "4px" }, + ]; + this._sparkles = corners.map((pos, i) => { + const el = document.createElement("div"); + el.className = `legendary-sparkle-${i}`; + el.textContent = "✦"; + el.style.cssText = ` + pointer-events: none; + position: absolute; + font-size: 10px; + color: rgba(255,220,100,0.9); + text-shadow: 0 0 6px rgba(255,200,60,1); + z-index: 11; + opacity: 0; + display: none; + line-height: 1; + `; + Object.assign(el.style, pos); + this.appendChild(el); + return el; + }); + } + + private _onClick = () => { + if (CosmeticContainer._backdrop) { + CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)"; + } + if (this.product && this.onPurchase && !this._loading) { + this._loading = true; + this._showLoadingOverlay(); + Promise.resolve(this.onPurchase()).catch(() => { + this._hideLoadingOverlay(); + }); + } + }; + + private _showLoadingOverlay() { + if (this._loadingOverlay) return; + const overlay = document.createElement("div"); + overlay.className = "cosmetic-loading-overlay"; + overlay.innerHTML = `
`; + this.appendChild(overlay); + this._loadingOverlay = overlay; + } + + private _hideLoadingOverlay() { + this._loadingOverlay?.remove(); + this._loadingOverlay = null; + this._loading = false; + } + + private _onMouseEnter = () => { + if (this._hasGlint || this._hasBorderSweep) { + this._ensureLegendaryElements(); + } + if (this._isLegendary) { + this.style.transform = "scale(1.12)"; + this.style.zIndex = "10"; + this.classList.add("legendary-hovered"); + this._sparkles.forEach((s) => (s.style.display = "block")); + CosmeticContainer._ensureBackdrop().style.background = "rgba(0,0,0,0.6)"; + } + if (this._hasBorderSweep && this._borderSweep) { + this._borderSweep.style.display = "block"; + } + if (this._hasGlint && this._shimmer) { + this._shimmer.style.display = "block"; + this._shimmer.classList.remove("active"); + void this._shimmer.offsetWidth; + this._shimmer.classList.add("active"); + } + if (!this._isLegendary && !this.selected) { + this.style.boxShadow = `0 0 ${this._glowSize} ${this._glowColor}`; + } + }; + + private _onMouseLeave = () => { + if (this._isLegendary) { + this.style.transform = ""; + this.style.zIndex = "0"; + this.classList.remove("legendary-hovered"); + this._sparkles.forEach((s) => (s.style.display = "none")); + if (CosmeticContainer._backdrop) { + CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)"; + } + } + if (this._hasGlint && this._shimmer) this._shimmer.style.display = "none"; + if (this._hasBorderSweep && this._borderSweep) + this._borderSweep.style.display = "none"; + if (!this.selected) this.style.boxShadow = ""; + }; + + private _nameEl: HTMLDivElement | null = null; + + private _updateNameEl() { + if (this.name) { + this._nameEl ??= document.createElement("div"); + const cfg = rarityConfig[this.rarity] ?? fallback; + this._nameEl.className = `text-xs font-bold uppercase tracking-wider text-center truncate w-full`; + this._nameEl.style.color = cfg.nameColor; + this._nameEl.title = this.name; + this._nameEl.textContent = this.name; + // Always ensure it's the first child + if (this.firstChild !== this._nameEl) { + this.prepend(this._nameEl); + } + } else if (this._nameEl) { + this._nameEl.remove(); + this._nameEl = null; + } + } + + connectedCallback() { + super.connectedCallback(); + this.applyHostStyles(); + this._updateNameEl(); + this.addEventListener("mouseenter", this._onMouseEnter); + this.addEventListener("mouseleave", this._onMouseLeave); + this.addEventListener("click", this._onClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener("mouseenter", this._onMouseEnter); + this.removeEventListener("mouseleave", this._onMouseLeave); + this.removeEventListener("click", this._onClick); + } + + updated() { + this.applyHostStyles(); + this._updateNameEl(); + } + + render() { + return html` + + ${this.product && this.onPurchase + ? html`` + : null} + `; + } +} diff --git a/src/client/components/CosmeticInfo.ts b/src/client/components/CosmeticInfo.ts new file mode 100644 index 000000000..5958c46ec --- /dev/null +++ b/src/client/components/CosmeticInfo.ts @@ -0,0 +1,74 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateCosmetic } from "../Cosmetics"; +import { translateText } from "../Utils"; + +const rarityColors: Record = { + common: "text-white/60", + uncommon: "text-green-400", + rare: "text-blue-400", + epic: "text-purple-300", + legendary: "text-orange-400", +}; + +@customElement("cosmetic-info") +export class CosmeticInfo extends LitElement { + @property({ type: String }) + artist?: string; + + @property({ type: String }) + rarity?: string; + + @property({ type: String }) + colorPalette?: string; + + createRenderRoot() { + return this; + } + + render() { + if (!this.artist && !this.rarity && !this.colorPalette) { + return nothing; + } + + const rarityColor = rarityColors[this.rarity ?? ""] ?? "text-white/70"; + + return html` +
e.stopPropagation()} + > +
+ ? +
+ +
+ `; + } +} diff --git a/src/client/components/FlagButton.ts b/src/client/components/FlagButton.ts index 938087ef2..2e209991c 100644 --- a/src/client/components/FlagButton.ts +++ b/src/client/components/FlagButton.ts @@ -1,17 +1,11 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { Product } from "../../core/CosmeticSchemas"; +import { Flag } from "../../core/CosmeticSchemas"; import { translateCosmetic } from "../Cosmetics"; -import "./ArtistInfo"; -import "./PurchaseButton"; +import "./CosmeticContainer"; +import "./CosmeticInfo"; -export interface FlagItem { - key: string; - name: string; - url: string; - product?: Product | null; - artist?: string; -} +export type FlagItem = Flag & { key: string }; @customElement("flag-button") export class FlagButton extends LitElement { @@ -35,35 +29,33 @@ export class FlagButton extends LitElement { } private handleClick() { + if (this.requiresPurchase) { + this.onPurchase?.(); + return; + } this.onSelect?.(this.flag.key); } render() { return html` -
this.onPurchase?.()} + .name=${translateCosmetic("flags", this.flag.name)} > - - ${this.requiresPurchase && this.flag.product - ? html` - this.onPurchase?.()} - > - ` - : null} -
+ `; } } diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index fb9aba4c8..6f52633ca 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -11,8 +11,8 @@ import { PatternDecoder } from "../../core/PatternDecoder"; import { PlayerPattern } from "../../core/Schemas"; import { translateCosmetic } from "../Cosmetics"; import { translateText } from "../Utils"; -import "./ArtistInfo"; -import "./PurchaseButton"; +import "./CosmeticContainer"; +import "./CosmeticInfo"; export const BUTTON_WIDTH = 150; @@ -40,6 +40,10 @@ export class PatternButton extends LitElement { } private handleClick() { + if (this.requiresPurchase) { + this.handlePurchase(); + return; + } if (this.pattern === null) { this.onSelect?.(null); return; @@ -61,57 +65,27 @@ export class PatternButton extends LitElement { const isDefaultPattern = this.pattern === null; return html` -
this.handlePurchase()} + .name=${isDefaultPattern + ? translateText("territory_patterns.pattern.default") + : translateCosmetic("territory_patterns.pattern", this.pattern!.name)} > - - ${this.requiresPurchase && this.pattern?.product - ? html` - this.handlePurchase()} - > - ` - : null} -
+ `; } } diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts index 47bacf4b1..e2c08817a 100644 --- a/src/client/components/PurchaseButton.ts +++ b/src/client/components/PurchaseButton.ts @@ -3,11 +3,156 @@ import { customElement, property } from "lit/decorators.js"; import { Product } from "../../core/CosmeticSchemas"; import { translateText } from "../Utils"; +const PURCHASE_STYLE_ID = "purchase-button-styles"; +if (!document.getElementById(PURCHASE_STYLE_ID)) { + const style = document.createElement("style"); + style.id = PURCHASE_STYLE_ID; + style.textContent = ` + @keyframes purchase-streak { + 0% { left: -60%; opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { left: 160%; opacity: 0; } + } + .purchase-sparkle-streak { + pointer-events: none; + position: absolute; + top: 0; + left: -60%; + width: 40%; + height: 100%; + background: linear-gradient(90deg, transparent 0%, rgba(134,239,172,0.5) 50%, transparent 100%); + transform: skewX(-15deg); + opacity: 0; + } + cosmetic-container:hover .purchase-sparkle-streak { + animation: purchase-streak 0.7s ease-in-out; + } + cosmetic-container:hover .purchase-sparkle-btn { + background: rgb(34,197,94); + border-color: rgb(74,222,128); + color: white; + box-shadow: 0 0 20px rgba(74,222,128,0.6); + } + @keyframes purchase-pulse { + 0% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); } + 50% { box-shadow: 0 0 25px rgba(74,222,128,0.9), 0 0 50px rgba(34,197,94,0.5); } + 100% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); } + } + .purchase-sparkle-btn:hover { + background: rgb(22,163,74) !important; + border-color: rgb(74,222,128) !important; + color: white !important; + animation: purchase-pulse 1.2s ease-in-out infinite !important; + } + @keyframes purchase-ember-0 { + 0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; } + 100% { transform: translateY(-35px) translateX(5px) scale(0.2); opacity: 0; } + } + @keyframes purchase-ember-1 { + 0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; } + 100% { transform: translateY(-30px) translateX(-6px) scale(0.3); opacity: 0; } + } + @keyframes purchase-ember-2 { + 0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; } + 100% { transform: translateY(-40px) translateX(3px) scale(0.2); opacity: 0; } + } + @keyframes purchase-ember-3 { + 0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; } + 100% { transform: translateY(-28px) translateX(-4px) scale(0.3); opacity: 0; } + } + .purchase-ember { + pointer-events: none; + position: absolute; + top: 0; + width: 3px; + height: 3px; + border-radius: 50%; + background: rgba(74,222,128,0.9); + box-shadow: 0 0 4px rgba(74,222,128,0.8); + opacity: 0; + display: none; + } + .purchase-ember-0 { left: 20%; animation: purchase-ember-0 1.2s ease-out infinite; } + .purchase-ember-1 { left: 40%; animation: purchase-ember-1 1.5s ease-out infinite 0.25s; } + .purchase-ember-2 { left: 60%; animation: purchase-ember-2 1.3s ease-out infinite 0.5s; } + .purchase-ember-3 { left: 80%; animation: purchase-ember-3 1.6s ease-out infinite 0.15s; } + cosmetic-container:hover .purchase-ember { + display: block; + } + @keyframes purchase-burst-a { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-70px) translateX(14px) scale(0); opacity:0; } } + @keyframes purchase-burst-b { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-60px) translateX(-12px) scale(0); opacity:0; } } + @keyframes purchase-burst-c { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-80px) translateX(8px) scale(0); opacity:0; } } + @keyframes purchase-burst-d { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-55px) translateX(-16px) scale(0); opacity:0; } } + @keyframes purchase-burst-e { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-75px) translateX(18px) scale(0); opacity:0; } } + @keyframes purchase-burst-f { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-65px) translateX(-6px) scale(0); opacity:0; } } + .purchase-burst { + pointer-events: none; + position: absolute; + top: 0; + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(74,222,128,1); + box-shadow: 0 0 6px rgba(74,222,128,0.9), 0 0 2px rgba(255,255,255,0.5); + opacity: 0; + display: none; + } + .purchase-burst-0 { left: 3%; animation: purchase-burst-a 0.9s ease-out infinite 0.00s; } + .purchase-burst-1 { left: 8%; animation: purchase-burst-d 1.1s ease-out infinite 0.73s; } + .purchase-burst-2 { left: 12%; animation: purchase-burst-c 0.95s ease-out infinite 0.41s; } + .purchase-burst-3 { left: 16%; animation: purchase-burst-f 1.05s ease-out infinite 0.17s; } + .purchase-burst-4 { left: 20%; animation: purchase-burst-b 0.85s ease-out infinite 0.89s; } + .purchase-burst-5 { left: 24%; animation: purchase-burst-e 1.0s ease-out infinite 0.53s; } + .purchase-burst-6 { left: 28%; animation: purchase-burst-a 1.1s ease-out infinite 0.29s; } + .purchase-burst-7 { left: 32%; animation: purchase-burst-c 0.9s ease-out infinite 0.97s; } + .purchase-burst-8 { left: 36%; animation: purchase-burst-f 1.05s ease-out infinite 0.61s; } + .purchase-burst-9 { left: 40%; animation: purchase-burst-d 0.95s ease-out infinite 0.07s; } + .purchase-burst-10 { left: 44%; animation: purchase-burst-b 1.0s ease-out infinite 0.83s; } + .purchase-burst-11 { left: 48%; animation: purchase-burst-e 0.85s ease-out infinite 0.37s; } + .purchase-burst-12 { left: 52%; animation: purchase-burst-a 1.1s ease-out infinite 0.67s; } + .purchase-burst-13 { left: 56%; animation: purchase-burst-f 0.9s ease-out infinite 0.11s; } + .purchase-burst-14 { left: 60%; animation: purchase-burst-c 1.05s ease-out infinite 0.79s; } + .purchase-burst-15 { left: 64%; animation: purchase-burst-d 0.95s ease-out infinite 0.47s; } + .purchase-burst-16 { left: 68%; animation: purchase-burst-b 1.0s ease-out infinite 0.23s; } + .purchase-burst-17 { left: 72%; animation: purchase-burst-e 0.85s ease-out infinite 1.03s; } + .purchase-burst-18 { left: 76%; animation: purchase-burst-a 1.1s ease-out infinite 0.57s; } + .purchase-burst-19 { left: 80%; animation: purchase-burst-f 0.95s ease-out infinite 0.31s; } + .purchase-burst-20 { left: 6%; animation: purchase-burst-b 0.92s ease-out infinite 0.15s; } + .purchase-burst-21 { left: 14%; animation: purchase-burst-e 1.08s ease-out infinite 0.86s; } + .purchase-burst-22 { left: 22%; animation: purchase-burst-a 0.88s ease-out infinite 0.44s; } + .purchase-burst-23 { left: 30%; animation: purchase-burst-d 1.02s ease-out infinite 0.71s; } + .purchase-burst-24 { left: 38%; animation: purchase-burst-f 0.93s ease-out infinite 0.03s; } + .purchase-burst-25 { left: 46%; animation: purchase-burst-c 1.07s ease-out infinite 0.59s; } + .purchase-burst-26 { left: 54%; animation: purchase-burst-b 0.87s ease-out infinite 0.92s; } + .purchase-burst-27 { left: 62%; animation: purchase-burst-e 0.98s ease-out infinite 0.26s; } + .purchase-burst-28 { left: 70%; animation: purchase-burst-a 1.12s ease-out infinite 0.64s; } + .purchase-burst-29 { left: 78%; animation: purchase-burst-d 0.91s ease-out infinite 0.38s; } + .purchase-burst-30 { left: 84%; animation: purchase-burst-c 1.03s ease-out infinite 0.77s; } + .purchase-burst-31 { left: 88%; animation: purchase-burst-f 0.86s ease-out infinite 0.09s; } + .purchase-burst-32 { left: 92%; animation: purchase-burst-b 1.06s ease-out infinite 0.52s; } + .purchase-burst-33 { left: 96%; animation: purchase-burst-e 0.94s ease-out infinite 0.81s; } + .purchase-burst-34 { left: 10%; animation: purchase-burst-d 0.89s ease-out infinite 0.34s; } + .purchase-burst-35 { left: 26%; animation: purchase-burst-a 1.04s ease-out infinite 0.96s; } + .purchase-burst-36 { left: 42%; animation: purchase-burst-f 0.91s ease-out infinite 0.19s; } + .purchase-burst-37 { left: 58%; animation: purchase-burst-c 1.09s ease-out infinite 0.69s; } + .purchase-burst-38 { left: 74%; animation: purchase-burst-b 0.87s ease-out infinite 0.46s; } + .purchase-burst-39 { left: 90%; animation: purchase-burst-e 1.01s ease-out infinite 0.13s; } + .purchase-btn-wrap:hover .purchase-burst { + display: block; + } + `; + document.head.appendChild(style); +} + @customElement("purchase-button") export class PurchaseButton extends LitElement { @property({ type: Object }) product!: Product; + @property({ type: String }) + rarity: string = "common"; + @property({ type: Function }) onPurchase?: () => void; @@ -17,19 +162,42 @@ export class PurchaseButton extends LitElement { private handleClick(e: Event) { e.stopPropagation(); - this.onPurchase?.(); + const container = this.closest("cosmetic-container") as HTMLElement | null; + if (container && !container.querySelector(".cosmetic-loading-overlay")) { + const overlay = document.createElement("div"); + overlay.className = "cosmetic-loading-overlay"; + overlay.innerHTML = `
`; + container.appendChild(overlay); + } + Promise.resolve(this.onPurchase?.()).catch(() => { + container?.querySelector(".cosmetic-loading-overlay")?.remove(); + }); } render() { return html` -
+
+ ${this.rarity !== "common" + ? html` + + + + ${Array.from( + { length: 40 }, + (_, i) => + html``, + )}` + : null}
`; diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 586a44055..bd808cdcc 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -51,8 +51,17 @@ export const ColorPaletteSchema = z.object({ secondaryColor: z.string(), }); -export const PatternSchema = z.object({ +const CosmeticSchema = z.object({ name: CosmeticNameSchema, + affiliateCode: z.string().nullable(), + product: ProductSchema.nullable(), + artist: z.string().optional(), + rarity: z + .enum(["common", "uncommon", "rare", "epic", "legendary"]) + .or(z.string()), +}); + +export const PatternSchema = CosmeticSchema.extend({ pattern: PatternDataSchema, colorPalettes: z .object({ @@ -61,17 +70,10 @@ export const PatternSchema = z.object({ }) .array() .optional(), - affiliateCode: z.string().nullable(), - product: ProductSchema.nullable(), - artist: z.string().optional(), }); -export const FlagSchema = z.object({ - name: CosmeticNameSchema, +export const FlagSchema = CosmeticSchema.extend({ url: z.string(), - affiliateCode: z.string().nullable(), - product: ProductSchema.nullable(), - artist: z.string().optional(), }); // Schema for resources/cosmetics/cosmetics.json diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 3d70bcf5a..dbf471871 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -43,6 +43,7 @@ const flagCosmetics = { url: "https://example.com/cool.png", affiliateCode: null, product: { productId: "prod_1", priceId: "price_1", price: "$4.99" }, + rarity: "common", }, }, }; From 10d02de401084a7af0e1734267e6047c6373a37a Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 6 Apr 2026 11:49:30 -0700 Subject: [PATCH 3/8] Admiral (#3522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Use admiral to detect adblock and ask users to disable Screenshot 2026-04-06 at 11 46
09 AM ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- index.html | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/index.html b/index.html index 3ef165efd..1386ecd32 100644 --- a/index.html +++ b/index.html @@ -423,6 +423,67 @@ async src="//cdn.intergient.com/1025558/75940/ramp.js" > + + + + From bfa259a6878838ecf57a13925b2779a13fdb3121 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 6 Apr 2026 13:40:09 -0700 Subject: [PATCH 4/8] add .claude/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f97753a02..4d11d0efa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ resources/.DS_Store .DS_Store .clinic/ CLAUDE.md +.claude/ .idea/ # this is autogenerated by script src/assets/ From 4dba57ae909be34dc78894c951df6298d03dfd14 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 6 Apr 2026 13:53:26 -0700 Subject: [PATCH 5/8] Revert "Admiral (#3522)" This reverts commit 10d02de401084a7af0e1734267e6047c6373a37a. --- index.html | 61 ------------------------------------------------------ 1 file changed, 61 deletions(-) diff --git a/index.html b/index.html index 1386ecd32..3ef165efd 100644 --- a/index.html +++ b/index.html @@ -423,67 +423,6 @@ async src="//cdn.intergient.com/1025558/75940/ramp.js" > - - - - From 0a117aead3251acd3ba85d9a9ae2c65d49232f0e Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 6 Apr 2026 14:05:50 -0700 Subject: [PATCH 6/8] add adfree description to cosmetics --- resources/lang/en.json | 3 ++- src/client/components/CosmeticInfo.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 00aee7e70..874045ef1 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -926,7 +926,8 @@ "uncommon": "Uncommon", "rare": "Rare", "epic": "Epic", - "legendary": "Legendary" + "legendary": "Legendary", + "adfree": "ad-free for life!" }, "flag_input": { "title": "Select Flag", diff --git a/src/client/components/CosmeticInfo.ts b/src/client/components/CosmeticInfo.ts index 5958c46ec..150df5155 100644 --- a/src/client/components/CosmeticInfo.ts +++ b/src/client/components/CosmeticInfo.ts @@ -53,6 +53,9 @@ export class CosmeticInfo extends LitElement { ${translateText(`cosmetics.${this.rarity}`) || this.rarity}
` : nothing} +
+ ${translateText("cosmetics.adfree")} +
${this.colorPalette ? html`
${translateText("cosmetics.color_label")} From 2f95314dce2c0cf157be7a24c2f130986429439a Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 6 Apr 2026 14:18:32 -0700 Subject: [PATCH 7/8] Update name censor to check for certain substrings (#3603) ## Description: The deduper was converting profane words like "kkk" => "k" and then censoring all usernames with the letter "k", so instead we just hardcode and check for substrings for profane phrases like that. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/server/Privilege.ts | 8 ++++++-- tests/Privilege.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 26fefc0da..ab06a48aa 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -84,7 +84,9 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { }); return { hasMatch: (input: string) => - substringMatcher.hasMatch(input) || collapseMatcher.hasMatch(input), + input.toLowerCase().includes("kkk") || + substringMatcher.hasMatch(input) || + collapseMatcher.hasMatch(input), getAllMatches: (input: string, sorted?: boolean) => [ ...substringMatcher.getAllMatches(input, sorted), ...collapseMatcher.getAllMatches(input, sorted), @@ -118,7 +120,9 @@ function censorUsernameWithMatcher( ? username.replace(`[${clanTag}]`, "").trim() : username; - const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false; + const clanTagIsProfane = clanTag + ? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss" + : false; const usernameIsProfane = matcher.hasMatch(nameWithoutClan); const censoredName = usernameIsProfane diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index dbf471871..bedfc797b 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -131,6 +131,13 @@ describe("UsernameCensor", () => { // "snigger" is whitelisted in englishDataset expect(matcher.hasMatch("snigger")).toBe(false); }); + + test("catches kkk as substring", () => { + expect(matcher.hasMatch("kkk")).toBe(true); + expect(matcher.hasMatch("KKK")).toBe(true); + expect(matcher.hasMatch("kkklover")).toBe(true); + expect(matcher.hasMatch("ilovekkkboys")).toBe(true); + }); }); describe("censorUsername", () => { @@ -186,6 +193,15 @@ describe("UsernameCensor", () => { expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); }); + test("removes [SS] clan tag", () => { + expect(checker.censorUsername("[SS]Player")).toBe("Player"); + expect(checker.censorUsername("[ss]Player")).toBe("Player"); + }); + + test("removes [KKK] clan tag", () => { + expect(checker.censorUsername("[KKK]Player")).toBe("Player"); + }); + test("keeps clean clan tag when username is clean", () => { expect(checker.censorUsername("[COOL]Player")).toBe("[COOL] Player"); expect(checker.censorUsername("[PRO]Player")).toBe("[PRO] Player"); From 592dadf80df68bff0240e5f06f1a0aaccc182d52 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 6 Apr 2026 16:44:41 -0700 Subject: [PATCH 8/8] Refactor home page ads into a single file, add corner video ad (#3601) ## Description: image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- index.html | 2 +- src/client/GutterAds.ts | 162 ---------------- src/client/HomepagePromos.ts | 259 ++++++++++++++++++++++++++ src/client/Main.ts | 13 +- src/client/components/HomeFooterAd.ts | 86 --------- 5 files changed, 265 insertions(+), 257 deletions(-) delete mode 100644 src/client/GutterAds.ts create mode 100644 src/client/HomepagePromos.ts delete mode 100644 src/client/components/HomeFooterAd.ts diff --git a/index.html b/index.html index 3ef165efd..646e012db 100644 --- a/index.html +++ b/index.html @@ -174,7 +174,7 @@ class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-99999" >
- + diff --git a/src/client/GutterAds.ts b/src/client/GutterAds.ts deleted file mode 100644 index f887fd26e..000000000 --- a/src/client/GutterAds.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { FOOTER_AD_MIN_HEIGHT } from "./components/HomeFooterAd"; - -@customElement("gutter-ads") -export class GutterAds extends LitElement { - @state() - private isVisible: boolean = false; - - @state() - private adLoaded: boolean = false; - - @state() - private hasFooterAd: boolean = false; - - private onResize = () => { - const isDesktop = window.innerWidth >= 640; - this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT; - }; - - private leftAdType: string = "standard_iab_left2"; - private rightAdType: string = "standard_iab_rght1"; - private leftContainerId: string = "gutter-ad-container-left"; - private rightContainerId: string = "gutter-ad-container-right"; - - // Override createRenderRoot to disable shadow DOM - createRenderRoot() { - return this; - } - - static styles = css``; - - connectedCallback() { - super.connectedCallback(); - this.onResize(); - window.addEventListener("resize", this.onResize); - document.addEventListener("userMeResponse", () => { - if (window.adsEnabled) { - console.log("showing gutter ads"); - this.show(); - } else { - console.log("not showing gutter ads"); - } - }); - } - - // Called after the component's DOM is first rendered - firstUpdated() { - // DOM is guaranteed to be available here - console.log("GutterAdModal DOM is ready"); - } - - public show(): void { - this.isVisible = true; - this.requestUpdate(); - - // Wait for the update to complete, then load ads - this.updateComplete.then(() => { - this.loadAds(); - }); - } - - public close(): void { - try { - window.ramp.destroyUnits(this.leftAdType); - window.ramp.destroyUnits(this.rightAdType); - console.log("successfully destroyed gutter ads"); - } catch (e) { - console.error("error destroying gutter ads", e); - } - } - - private loadAds(): void { - console.log("loading ramp ads"); - // Ensure the container elements exist before loading ads - const leftContainer = this.querySelector(`#${this.leftContainerId}`); - const rightContainer = this.querySelector(`#${this.rightContainerId}`); - - if (!leftContainer || !rightContainer) { - console.warn("Ad containers not found in DOM"); - return; - } - - if (!window.ramp) { - console.warn("Playwire RAMP not available"); - return; - } - - if (this.adLoaded) { - console.log("Ads already loaded, skipping"); - return; - } - - try { - window.ramp.que.push(() => { - try { - window.ramp.spaAddAds([ - { - type: this.leftAdType, - selectorId: this.leftContainerId, - }, - { - type: this.rightAdType, - selectorId: this.rightContainerId, - }, - ]); - this.adLoaded = true; - console.log( - "Playwire ads loaded:", - this.leftAdType, - this.rightAdType, - ); - } catch (e) { - console.log(e); - } - }); - } catch (error) { - console.error("Failed to load Playwire ads:", error); - } - } - - disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener("resize", this.onResize); - } - - render() { - if (!this.isVisible) { - return html``; - } - - return html` - - - - - - `; - } -} diff --git a/src/client/HomepagePromos.ts b/src/client/HomepagePromos.ts new file mode 100644 index 000000000..a4c0c3266 --- /dev/null +++ b/src/client/HomepagePromos.ts @@ -0,0 +1,259 @@ +import { LitElement, css, html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +export const FOOTER_AD_MIN_HEIGHT = 880; + +const FOOTER_AD_TYPE = "standard_iab_head2"; +const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container"; + +// ─── Gutter Ads ────────────────────────────────────────────────────────────── + +@customElement("homepage-promos") +export class HomepagePromos extends LitElement { + @state() private isVisible: boolean = false; + @state() private adLoaded: boolean = false; + private cornerAdLoaded: boolean = false; + @state() private hasFooterAd: boolean = false; + + private onResize = () => { + const isDesktop = window.innerWidth >= 640; + this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT; + }; + + private onUserMeResponse = () => { + if (window.adsEnabled) { + console.log("showing homepage ads"); + this.show(); + this.loadCornerAdVideo(); + } else { + console.log("not showing homepage ads"); + } + }; + + private leftAdType: string = "standard_iab_left2"; + private rightAdType: string = "standard_iab_rght1"; + private leftContainerId: string = "gutter-ad-container-left"; + private rightContainerId: string = "gutter-ad-container-right"; + + createRenderRoot() { + return this; + } + + static styles = css``; + + connectedCallback() { + super.connectedCallback(); + this.onResize(); + window.addEventListener("resize", this.onResize); + document.addEventListener("userMeResponse", this.onUserMeResponse); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this.onResize); + document.removeEventListener("userMeResponse", this.onUserMeResponse); + } + + public show(): void { + this.isVisible = true; + this.requestUpdate(); + this.updateComplete.then(() => { + this.loadGutterAds(); + }); + } + + public close(): void { + try { + // Keep corner video ad alive. + window.ramp.destroyUnits(this.leftAdType); + window.ramp.destroyUnits(this.rightAdType); + console.log("successfully destroyed gutter ads"); + } catch (e) { + console.error("error destroying gutter ads", e); + } + } + + private loadGutterAds(): void { + console.log("loading ramp gutter ads"); + const leftContainer = this.querySelector(`#${this.leftContainerId}`); + const rightContainer = this.querySelector(`#${this.rightContainerId}`); + + if (!leftContainer || !rightContainer) { + console.warn("Ad containers not found in DOM"); + return; + } + + if (!window.ramp) { + console.warn("Playwire RAMP not available"); + return; + } + + if (this.adLoaded) { + console.log("Ads already loaded, skipping"); + return; + } + + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { type: this.leftAdType, selectorId: this.leftContainerId }, + { type: this.rightAdType, selectorId: this.rightContainerId }, + ]); + this.adLoaded = true; + console.log("Gutter ads loaded:", this.leftAdType, this.rightAdType); + } catch (e) { + console.log(e); + } + }); + } catch (error) { + console.error("Failed to load gutter ads:", error); + } + } + + private loadCornerAdVideo(): void { + if (this.cornerAdLoaded) return; + if (window.innerWidth < 1280) return; + if (!window.ramp) { + console.warn("Playwire RAMP not available for corner_ad_video"); + return; + } + try { + window.ramp.que.push(() => { + try { + window.ramp + .addUnits([{ type: "corner_ad_video" }]) + .then(() => { + this.cornerAdLoaded = true; + window.ramp.displayUnits(); + console.log("corner_ad_video loaded"); + }) + .catch((e: unknown) => { + console.error("Failed to display corner_ad_video:", e); + }); + } catch (e) { + console.error("Failed to add corner_ad_video:", e); + } + }); + } catch (error) { + console.error("Failed to load corner_ad_video:", error); + } + } + + render() { + if (!this.isVisible) { + return html``; + } + + return html` + + + + + + `; + } +} + +// ─── Footer Ad ─────────────────────────────────────────────────────────────── + +@customElement("home-footer-ad") +export class HomeFooterAd extends LitElement { + @state() private shouldShow: boolean = false; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + document.addEventListener("userMeResponse", this.onUserMeResponse); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("userMeResponse", this.onUserMeResponse); + this.destroyAd(); + } + + private onUserMeResponse = () => { + const isDesktop = window.innerWidth >= 640; + if ( + !window.adsEnabled || + (isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT) + ) { + return; + } + this.shouldShow = true; + this.updateComplete.then(() => { + this.loadAd(); + }); + }; + + private loadAd(): void { + if (!window.ramp) { + console.warn("Playwire RAMP not available for footer ad"); + return; + } + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID }, + ]); + console.log("Footer ad loaded:", FOOTER_AD_TYPE); + } catch (e) { + console.error("Failed to add footer ad:", e); + } + }); + } catch (error) { + console.error("Failed to load footer ad:", error); + } + } + + private destroyAd(): void { + try { + window.ramp.destroyUnits(FOOTER_AD_TYPE); + console.log("successfully destroyed footer ad"); + } catch (e) { + console.error("error destroying footer ad", e); + } + } + + render() { + if (!this.shouldShow) { + return nothing; + } + + return html` +
+ `; + } +} diff --git a/src/client/Main.ts b/src/client/Main.ts index a8331927c..bfe79d1e0 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -27,8 +27,8 @@ import "./GameModeSelector"; import { GameModeSelector } from "./GameModeSelector"; import { GameStartingModal } from "./GameStartingModal"; import "./GoogleAdElement"; -import { GutterAds } from "./GutterAds"; import { HelpModal } from "./HelpModal"; +import { HomepagePromos } from "./HomepagePromos"; import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal"; import { JoinLobbyModal } from "./JoinLobbyModal"; import "./LangSelector"; @@ -60,7 +60,6 @@ import { } from "./Utils"; import "./components/DesktopNavBar"; import "./components/Footer"; -import "./components/HomeFooterAd"; import "./components/MainLayout"; import "./components/MobileNavBar"; import "./components/PlayPage"; @@ -245,7 +244,6 @@ class Client { private tokenLoginModal: TokenLoginModal; private matchmakingModal: MatchmakingModal; - private gutterAds: GutterAds; private turnstileTokenPromise: Promise<{ token: string; createdAt: number; @@ -305,10 +303,9 @@ class Client { } }); - const gutterAds = document.querySelector("gutter-ads"); - if (!(gutterAds instanceof GutterAds)) - throw new Error("Missing gutter-ads"); - this.gutterAds = gutterAds; + const gutterAds = document.querySelector("homepage-promos"); + if (!(gutterAds instanceof HomepagePromos)) + throw new Error("Missing homepage-promos"); document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); @@ -774,7 +771,7 @@ class Client { "token-login", "matchmaking-modal", "lang-selector", - "gutter-ads", + "homepage-promos", ].forEach((tag) => { const modal = document.querySelector(tag) as HTMLElement & { close?: () => void; diff --git a/src/client/components/HomeFooterAd.ts b/src/client/components/HomeFooterAd.ts deleted file mode 100644 index 0298bc09b..000000000 --- a/src/client/components/HomeFooterAd.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { LitElement, html, nothing } from "lit"; -import { customElement, state } from "lit/decorators.js"; - -export const FOOTER_AD_MIN_HEIGHT = 880; -const FOOTER_AD_TYPE = "standard_iab_head2"; -const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container"; - -@customElement("home-footer-ad") -export class HomeFooterAd extends LitElement { - @state() private shouldShow: boolean = false; - - createRenderRoot() { - return this; - } - - connectedCallback() { - super.connectedCallback(); - this.style.display = "contents"; - document.addEventListener("userMeResponse", this.onUserMeResponse); - } - - disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener("userMeResponse", this.onUserMeResponse); - this.destroyAd(); - } - - private onUserMeResponse = () => { - const isDesktop = window.innerWidth >= 640; - if ( - !window.adsEnabled || - (isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT) - ) { - return; - } - this.shouldShow = true; - this.updateComplete.then(() => { - this.loadAd(); - }); - }; - - private loadAd(): void { - if (!window.ramp) { - console.warn("Playwire RAMP not available for footer ad"); - return; - } - - try { - window.ramp.que.push(() => { - try { - window.ramp.spaAddAds([ - { type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID }, - ]); - console.log("Footer ad loaded:", FOOTER_AD_TYPE); - } catch (e) { - console.error("Failed to add footer ad:", e); - } - }); - } catch (error) { - console.error("Failed to load footer ad:", error); - } - } - - private destroyAd(): void { - try { - window.ramp.destroyUnits(FOOTER_AD_TYPE); - console.log("successfully destroyed footer ad"); - } catch (e) { - console.error("error destroying footer ad", e); - } - } - - render() { - if (!this.shouldShow) { - return nothing; - } - - return html` -
- `; - } -}