feat: effects cosmetic category (transport-ship trail) + UI (#4418)

## What

Adds a new **`effects`** cosmetic category alongside `skins`/`flags`.
Each effect is discriminated by **`effectType`** (only
`transportShipTrail` today), whose visual config lives in
**`attributes`** (`solid` / `rainbow` / `pulse` / `gradient`). Schema
matches the production cosmetics.json shape exactly (incl. the `url`
field).

**This PR is UI + taxonomy only — the in-game WebGL trail rendering is
intentionally deferred.**

## UI

- **Store** gains an **"Effects"** tab.
- **Home page** gains an **"Effects"** button opening a picker modal.
- Both render effects **grouped by `effectType` with a sub-header per
type**, via a shared `<effects-grid>` Lit element (`mode="select"` for
the picker, `mode="purchase"` for the store). The picker shows owned
effects + a Default tile and persists per-type; the store shows
purchasable effects.

## Data flow

- Ownership via `effect:*` / `effect:<name>` flares (reuses
`cosmeticRelationship`).
- Selection is a per-`effectType` map persisted in UserSettings
(`settings.effects`).
- Server validates in `isEffectAllowed`, wired into `isAllowed`.
- `getPlayerCosmeticsRefs` / `getPlayerCosmetics` resolve effects the
same way as skins/flags (kept-on-fetch-failure, server is authority).

## Tests

- `tsc --noEmit`, ESLint, Prettier clean; full suite green.
- New: `CosmeticSchemas` parse tests (incl. parsing the **real**
`read_transport_trail` entry), `UserSettings` per-type selection, and
`Privilege` effect validation.

## Notes / follow-ups

- The effect's display label shows **"Boat Trail"** for the
`transportShipTrail` type (friendlier than the id).
- Closed-source API gap: `/shop/purchase` (`purchaseWithCurrency`) needs
to learn `"effect"` for **currency** purchase of effects; the
**dollar/product** purchase path already works. Client types were
widened accordingly.
- In-game wake rendering can be ported from #4416.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-29 13:13:48 -07:00
committed by GitHub
parent ccd0745ad4
commit bd9ef9a317
20 changed files with 1198 additions and 10 deletions
+5
View File
@@ -272,6 +272,11 @@
inline
class="hidden w-full h-full page-content relative z-50"
></territory-patterns-modal>
<effects-modal
id="effects-modal"
inline
class="hidden w-full h-full page-content relative z-50"
></effects-modal>
<ranked-modal
id="page-ranked"
inline
+11 -2
View File
@@ -392,6 +392,13 @@
"discord_user_header": {
"avatar_alt": "Avatar"
},
"effects": {
"button_title": "Pick an effect!",
"title": "Effects",
"type": {
"transportShipTrail": "Boat Trail"
}
},
"error_modal": {
"connection_error": "Connection error!",
"copied": "Copied!",
@@ -450,7 +457,7 @@
"flag_input": {
"button_title": "Pick a flag!",
"search_flag": "Search...",
"title": "Select Flag"
"title": "Flag"
},
"friends": {
"accept": "Accept",
@@ -1255,8 +1262,10 @@
"confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.",
"confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.",
"currency_pack_purchase_success": "Currency pack purchase successful!",
"effects": "Effects",
"flags": "Flags",
"login_required": "You must be logged in to purchase with currency.",
"no_effects": "No effects available. Check back later for new items.",
"no_flags": "No flags available. Check back later for new items.",
"no_packs": "No packs available. Check back later for new items.",
"no_skins": "No skins available. Check back later for new items.",
@@ -1290,7 +1299,7 @@
"default": "Default"
},
"search": "Search...",
"select_skin": "Select Skin",
"select_skin": "Skin",
"selected": "selected",
"title": "Skins"
},
+1 -1
View File
@@ -95,7 +95,7 @@ export function invalidateUserMe() {
}
export async function purchaseWithCurrency(
cosmeticType: "pattern" | "skin" | "flag",
cosmeticType: "pattern" | "skin" | "flag" | "effect",
cosmeticName: string,
currencyType: "hard" | "soft",
colorPaletteName?: string,
+89 -3
View File
@@ -4,6 +4,9 @@ import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Effect,
EffectType,
findEffect,
Flag,
Pack,
Pattern,
@@ -14,6 +17,7 @@ import {
import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerEffect,
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
@@ -156,7 +160,7 @@ export async function purchaseCosmetic(
return;
}
const cosmeticType = resolved.type as "pattern" | "skin" | "flag";
const cosmeticType = resolved.type as "pattern" | "skin" | "flag" | "effect";
const success = await purchaseWithCurrency(
cosmeticType,
c.name,
@@ -357,13 +361,34 @@ export function skinRelationship(
);
}
export function effectRelationship(
effect: Effect,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "effect:*",
requiredFlare: `effect:${effect.name}`,
product: effect.product,
priceSoft: effect.priceSoft,
priceHard: effect.priceHard,
affiliateCode,
itemAffiliateCode: effect.affiliateCode ?? null,
},
userMeResponse,
);
}
export type ResolvedCosmetic = {
type: "pattern" | "skin" | "flag" | "pack" | "subscription";
cosmetic: Pattern | Skin | Flag | Pack | Subscription | null;
type: "pattern" | "skin" | "flag" | "effect" | "pack" | "subscription";
cosmetic: Pattern | Skin | Flag | Effect | Pack | Subscription | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
key: string;
/** For effects only: the effectType (also the catalog's outer key). */
effectType?: string;
};
/**
@@ -438,6 +463,23 @@ export function resolveCosmetics(
});
}
// Effects (boat-trail wakes, etc.) — a cosmetic category like skins/flags.
// Catalog is nested: effects[effectType][effectName]. We carry effectType (the
// outer key, which each effect also stores) on the resolved item.
for (const [effectType, byName] of Object.entries(cosmetics.effects ?? {})) {
for (const [effectKey, effect] of Object.entries(byName ?? {})) {
const rel = effectRelationship(effect, userMeResponse, affiliateCode);
result.push({
type: "effect",
cosmetic: effect,
colorPalette: null,
relationship: rel,
key: `effect:${effectType}:${effectKey}`,
effectType,
});
}
}
// Packs
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
const rel = pack.product ? "purchasable" : "blocked";
@@ -585,11 +627,41 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
}
}
// Effects: a per-effectType map (effectType -> effect name). Drop any entry
// whose effect no longer exists or the user can't access. Like
// skins/flags/patterns above, a selection is kept (and left to the server to
// validate) when cosmetics or userMe fail to load.
const selectedEffects = userSettings.getSelectedEffects();
const effects: Record<string, string> = {};
for (const [effectType, name] of Object.entries(selectedEffects)) {
const effect = findEffect(cosmetics, effectType, name);
if (cosmetics && !effect) {
userSettings.setSelectedEffectName(effectType as EffectType, undefined);
continue;
}
if (effect) {
const userMe = await getUserMe();
if (userMe) {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("effect:*");
if (!hasWildcard && !flares.includes(`effect:${effect.name}`)) {
userSettings.setSelectedEffectName(
effectType as EffectType,
undefined,
);
continue;
}
}
}
effects[effectType] = name;
}
return {
flag: flag ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
skinName,
effects: Object.keys(effects).length > 0 ? effects : undefined,
};
}
@@ -632,6 +704,20 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
}
}
if (refs.effects && cosmetics) {
const effects: Record<string, PlayerEffect> = {};
for (const [effectType, name] of Object.entries(refs.effects)) {
const effect = findEffect(cosmetics, effectType, name);
if (effect) {
effects[effectType] = {
name: effect.name,
effectType: effect.effectType,
};
}
}
if (Object.keys(effects).length > 0) result.effects = effects;
}
return result;
}
+105
View File
@@ -0,0 +1,105 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
findEffect,
TransportShipTrailAttributes,
} from "../core/CosmeticSchemas";
import {
EFFECTS_KEY,
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import { renderTransportShipTrailSwatch } from "./components/EffectPreview";
import { fetchCosmetics, getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@customElement("effects-input")
export class EffectsInput extends LitElement {
// The selected transport-ship-trail attributes, if any (one effectType today).
// Not named `attributes` — that collides with HTMLElement.attributes.
@state() private trailAttributes: TransportShipTrailAttributes | null = null;
private _abortController: AbortController | null = null;
// PlayerEffect is just { name, effectType }; resolve the visual style from the
// cosmetics catalog by (effectType, name).
private async resolveTrailAttributes(): Promise<TransportShipTrailAttributes | null> {
const cosmetics = await getPlayerCosmetics();
const name = cosmetics.effects?.["transportShipTrail"]?.name;
if (!name) return null;
const effect = findEffect(
await fetchCosmetics(),
"transportShipTrail",
name,
);
return effect?.effectType === "transportShipTrail"
? effect.attributes
: null;
}
private _onCosmeticSelected = async () => {
this.trailAttributes = await this.resolveTrailAttributes();
};
private onInputClick(e: Event) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("effects-input-click", {
bubbles: true,
composed: true,
}),
);
}
async connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
this.trailAttributes = await this.resolveTrailAttributes();
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
this._onCosmeticSelected,
{ signal: this._abortController.signal },
);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
}
createRenderRoot() {
return this;
}
render() {
if (crazyGamesSDK.isOnCrazyGames()) {
return html``;
}
const preview =
this.trailAttributes === null
? html`<span
class="text-[7px] lg:text-[10px] font-black tracking-wider text-white uppercase leading-tight lg:leading-none w-full text-center px-0.5 lg:px-1"
>
${translateText("effects.title")}
</span>`
: html`<span class="w-full h-full p-1.5"
>${renderTransportShipTrailSwatch(this.trailAttributes)}</span
>`;
return html`
<button
id="effects-input"
class="p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${translateText("effects.button_title")}
@click=${this.onInputClick}
>
${preview}
</button>
`;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import { BaseModal } from "./components/BaseModal";
import "./components/EffectsGrid";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("effects-modal")
export class EffectsModal extends BaseModal {
protected routerName = "effects";
@state() private cosmetics: Cosmetics | null = null;
@state() private userMeResponse: UserMeResponse | false = false;
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.requestUpdate();
}
protected renderHeaderSlot() {
return html`
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("effects.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
</div>
`;
}
protected renderBody() {
return html`
<div class="flex flex-col">
<div class="flex justify-center py-3 shrink-0">
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
></o-button>
</div>
<effects-grid
mode="select"
.cosmetics=${this.cosmetics}
.userMeResponse=${this.userMeResponse}
></effects-grid>
</div>
`;
}
}
+3
View File
@@ -235,6 +235,9 @@ export class LangSelector extends LitElement {
"leaderboard-modal",
"flag-input-modal",
"flag-input",
"effects-modal",
"effects-input",
"effects-grid",
"matchmaking-button",
"token-login",
];
+19
View File
@@ -20,6 +20,9 @@ import "./ClanModal";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import "./EffectsInput";
import "./EffectsModal";
import { EffectsModal } from "./EffectsModal";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
import "./FlagInputModal";
@@ -311,6 +314,7 @@ class Client {
tag: "territory-patterns-modal",
});
modalRouter.register("flag-input", { tag: "flag-input-modal" });
modalRouter.register("effects", { tag: "effects-modal" });
// Prefetch turnstile token so it is available when
// the user joins a lobby.
@@ -421,6 +425,20 @@ class Client {
});
});
const effectsModal = document.querySelector(
"effects-modal",
) as EffectsModal;
if (!effectsModal || !(effectsModal instanceof EffectsModal)) {
console.warn("Effects modal element not found");
}
document.querySelectorAll("effects-input").forEach((effectsInput) => {
effectsInput.addEventListener("effects-input-click", () => {
if (effectsModal && effectsModal instanceof EffectsModal) {
effectsModal.open();
}
});
});
this.storeModal = document.getElementById("page-item-store") as StoreModal;
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
@@ -864,6 +882,7 @@ class Client {
"language-modal",
"news-modal",
"flag-input-modal",
"effects-modal",
"account-button",
"leaderboard-button",
"token-login",
+17 -1
View File
@@ -6,6 +6,7 @@ import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
import "./components/EffectsGrid";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
@@ -17,7 +18,7 @@ import {
} from "./Cosmetics";
import { translateText } from "./Utils";
type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
type StoreTab = "patterns" | "flags" | "effects" | "packs" | "subscriptions";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@@ -44,6 +45,7 @@ export class StoreModal extends BaseModal {
: []),
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
{ key: "effects", label: translateText("store.effects") },
],
};
}
@@ -150,6 +152,17 @@ export class StoreModal extends BaseModal {
`;
}
private renderEffectGrid(): TemplateResult {
// Grouped by effectType with a sub-header per type (see <effects-grid>),
// matching the home selection modal.
return html`<effects-grid
mode="purchase"
.cosmetics=${this.cosmetics}
.userMeResponse=${this.userMeResponse}
.affiliateCode=${this.affiliateCode}
></effects-grid>`;
}
private renderPackGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
@@ -234,6 +247,8 @@ export class StoreModal extends BaseModal {
return this.renderPatternGrid();
case "flags":
return this.renderFlagGrid();
case "effects":
return this.renderEffectGrid();
case "subscriptions":
return this.renderSubscriptionGrid();
case "packs":
@@ -252,6 +267,7 @@ export class StoreModal extends BaseModal {
(r.type === "pattern" ||
r.type === "skin" ||
r.type === "flag" ||
r.type === "effect" ||
r.type === "pack") &&
r.relationship === "purchasable",
);
+20 -1
View File
@@ -1,6 +1,7 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
Effect,
Flag,
Pack,
Pattern,
@@ -17,6 +18,7 @@ import { translateText } from "../Utils";
import "./CapIcon";
import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderTransportShipTrailSwatch } from "./EffectPreview";
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
@@ -81,6 +83,9 @@ export class CosmeticButton extends LitElement {
if (this.activeResolved.type === "subscription") {
return translateCosmetic("subscriptions", c.name);
}
if (this.activeResolved.type === "effect") {
return translateCosmetic("effects", c.name);
}
return translateCosmetic("flags", c.name);
}
@@ -167,6 +172,20 @@ export class CosmeticButton extends LitElement {
/>`;
}
if (this.activeResolved.type === "effect") {
const c = this.activeResolved.cosmetic as Effect | null;
if (c === null) {
// "Default" tile — selecting it clears the effect for that type.
return html`<div
class="w-full h-full flex items-center justify-center text-white/40 text-xs uppercase"
>
${translateText("territory_patterns.pattern.default")}
</div>`;
}
// Only effectType today is transportShipTrail; c.attributes is its style.
return renderTransportShipTrailSwatch(c.attributes);
}
if (this.activeResolved.type === "pack") {
const pack = this.activeResolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
@@ -254,7 +273,7 @@ export class CosmeticButton extends LitElement {
render() {
const active = this.activeResolved;
const c = active.cosmetic;
const priced = c as Pattern | Skin | Flag | Pack | null;
const priced = c as Pattern | Skin | Flag | Effect | Pack | null;
const priceHard = priced?.priceHard;
const priceSoft = priced?.priceSoft;
const artist = priced?.artist;
+48
View File
@@ -0,0 +1,48 @@
import { html, TemplateResult } from "lit";
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
// A flowing spectrum used for the "rainbow" transport-ship-trail preview.
const RAINBOW_GRADIENT =
"linear-gradient(90deg,#ff0000,#ff8a00,#ffe600,#28c76f,#00a8ff,#7d5fff,#ff0000)";
// Neutral fallback for attribute types we don't recognize.
const UNKNOWN_BG = "#444";
/**
* Render a swatch preview of a transport-ship-trail's attributes, filling its
* container: solid = flat color, pulse = same color pulsing, rainbow = full
* spectrum, gradient = two-color blend. Unknown attribute types render a neutral
* swatch (we ignore types we don't know about).
*/
export function renderTransportShipTrailSwatch(
attributes: TransportShipTrailAttributes,
): TemplateResult {
switch (attributes.type) {
case "rainbow":
return html`<div
class="w-full h-full rounded-md"
style="background:${RAINBOW_GRADIENT};"
></div>`;
case "gradient":
return html`<div
class="w-full h-full rounded-md"
style="background:linear-gradient(90deg,${attributes.color},${attributes.color2});"
></div>`;
case "pulse":
return html`<div
class="w-full h-full rounded-md animate-pulse"
style="background:${attributes.color};"
></div>`;
case "solid":
return html`<div
class="w-full h-full rounded-md"
style="background:${attributes.color};"
></div>`;
default:
// Unknown / unrecognized style — neutral swatch.
return html`<div
class="w-full h-full rounded-md"
style="background:${UNKNOWN_BG};"
></div>`;
}
}
+161
View File
@@ -0,0 +1,161 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { UserMeResponse } from "../../core/ApiSchemas";
import {
Cosmetics,
Effect,
EFFECT_TYPES,
EffectType,
} from "../../core/CosmeticSchemas";
import {
EFFECTS_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../../core/game/UserSettings";
import {
purchaseCosmetic,
resolveCosmetics,
ResolvedCosmetic,
} from "../Cosmetics";
import { translateText } from "../Utils";
import "./CosmeticButton";
// "Default" (none) tile — selecting it clears the effect for that effectType.
function noneTile(effectType: EffectType): ResolvedCosmetic {
return {
type: "effect",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: `effect:none:${effectType}`,
effectType,
};
}
/**
* Renders effect cosmetics grouped by effectType, one sub-header per type.
* Shared by the home selection modal and the Store's Effects tab.
*
* - mode="select": owned effects + a Default tile per type; clicking persists
* the selection to UserSettings and re-renders.
* - mode="purchase": purchasable effects per type with the buy flow.
*/
@customElement("effects-grid")
export class EffectsGrid extends LitElement {
@property({ attribute: false }) cosmetics: Cosmetics | null = null;
@property({ attribute: false }) userMeResponse: UserMeResponse | false =
false;
@property({ type: String }) mode: "select" | "purchase" = "select";
@property({ attribute: false }) affiliateCode: string | null = null;
private userSettings = new UserSettings();
private _onChange = () => this.requestUpdate();
connectedCallback() {
super.connectedCallback();
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
this._onChange,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${EFFECTS_KEY}`,
this._onChange,
);
}
createRenderRoot() {
return this;
}
private select(effectType: EffectType, name: string | null) {
this.userSettings.setSelectedEffectName(effectType, name ?? undefined);
// Stay rendered; the change event re-renders this grid and the home button.
this.requestUpdate();
}
private itemsForType(
all: ResolvedCosmetic[],
effectType: EffectType,
): ResolvedCosmetic[] {
const ofType = all.filter(
(r) =>
r.type === "effect" &&
r.cosmetic !== null &&
r.effectType === effectType,
);
if (this.mode === "purchase") {
return ofType.filter((r) => r.relationship === "purchasable");
}
return [
noneTile(effectType),
...ofType.filter((r) => r.relationship === "owned"),
];
}
private renderTile(
effectType: EffectType,
r: ResolvedCosmetic,
): TemplateResult {
if (this.mode === "purchase") {
return html`<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>`;
}
const name = (r.cosmetic as Effect | null)?.name ?? null;
const selected = this.userSettings.getSelectedEffectName(effectType);
const isSelected =
(name === null && selected === null) ||
(name !== null && selected === name);
return html`<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${() => this.select(effectType, name)}
></cosmetic-button>`;
}
render() {
const all = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
);
const sections = EFFECT_TYPES.map((type) => ({
type,
items: this.itemsForType(all, type),
})).filter((s) => s.items.length > 0);
if (sections.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_effects")}
</div>`;
}
return html`
<div class="flex flex-col gap-4 p-4">
${sections.map(
(s) => html`
<div class="flex flex-col">
<h3
class="text-white/70 text-sm font-black uppercase tracking-wider px-2 pb-2 mb-2 border-b border-white/10"
>
${translateText(`effects.type.${s.type}`)}
</h3>
<div
class="flex flex-wrap gap-4 justify-center items-stretch content-start"
>
${s.items.map((r) => this.renderTile(s.type, r))}
</div>
</div>
`,
)}
</div>
`;
}
}
+8
View File
@@ -96,6 +96,10 @@ export class PlayPage extends LitElement {
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
></flag-input>
<effects-input
id="effects-input-mobile"
class="shrink-0 lg:hidden h-10 w-10"
></effects-input>
</div>
</div>
@@ -111,6 +115,10 @@ export class PlayPage extends LitElement {
show-select-label
class="flex-1 h-full"
></flag-input>
<effects-input
id="effects-input-desktop"
class="flex-1 h-full"
></effects-input>
</div>
</div>
+82
View File
@@ -9,6 +9,13 @@ export type Flag = z.infer<typeof FlagSchema>;
export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
// An effect cosmetic of any type — discriminated on effectType (today only
// transportShipTrail; gains a member per effectType).
export type Effect = z.infer<typeof EffectSchema>;
export type EffectType = z.infer<typeof EffectTypeSchema>;
export type TransportShipTrailAttributes = z.infer<
typeof TransportShipTrailAttributesSchema
>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
@@ -85,6 +92,49 @@ export const SkinSchema = CosmeticSchema.extend({
url: z.string(),
});
// "effects" is a cosmetic category alongside skins/flags. The catalog is nested
// effects[effectType][effectName], and each effect also carries an effectType
// field matching its outer key (so an Effect can stand alone / discriminate).
// effectTypes are listed explicitly in CosmeticsSchema so each type's attributes
// stay precisely typed; an effectType the client doesn't list is dropped at parse
// (the UI only handles EFFECT_TYPES), so a new server-side effectType never fails
// the whole cosmetics parse.
export const EFFECT_TYPES = ["transportShipTrail"] as const;
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
// Boat-trail styles, discriminated on `type`: each known style carries exactly
// the fields it uses (rainbow has none; solid/pulse need a color; gradient needs
// both). A `type` we don't recognize — a style shipped to cosmetics.json before
// this client updated — normalizes to { type: "unknown" } instead of failing the
// catalog parse, so one new style never wipes the whole catalog; the renderer
// shows a neutral swatch. `type` itself stays required.
export const TransportShipTrailAttributesSchema = z.union([
z.discriminatedUnion("type", [
z.object({ type: z.literal("solid"), color: z.string() }),
z.object({ type: z.literal("rainbow") }),
z.object({ type: z.literal("pulse"), color: z.string() }),
z.object({
type: z.literal("gradient"),
color: z.string(),
color2: z.string(),
}),
]),
z
.object({ type: z.string() })
.transform(() => ({ type: "unknown" as const })),
]);
const TransportShipTrailEffectSchema = CosmeticSchema.extend({
effectType: z.literal("transportShipTrail"),
attributes: TransportShipTrailAttributesSchema,
url: z.string().optional(),
});
// Any catalog effect, discriminated on effectType. Add a member per effectType.
export const EffectSchema = z.discriminatedUnion("effectType", [
TransportShipTrailEffectSchema,
]);
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
@@ -105,10 +155,42 @@ export const CosmeticsSchema = z.object({
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
skins: z.record(z.string(), SkinSchema).optional(),
// Grouped by effectType. Each effect also carries its own effectType (matching
// this outer key) so an Effect stands alone and EffectSchema can discriminate
// on it. Add a key per new effectType.
effects: z
.object({
transportShipTrail: z
.record(z.string(), TransportShipTrailEffectSchema)
.optional(),
})
.optional(),
currencyPacks: z.record(z.string(), PackSchema).optional(),
subscriptions: z.record(z.string(), SubscriptionSchema).optional(),
});
/**
* Resolve an effect in the nested catalog (effects[effectType][effectKey]). The
* catalog object key is normally identical to the effect's `name`, but selection
* and ownership flares are both name-based, so fall back to a `name`-field search
* when the object key differs. Without this fallback a catalog whose key !== name
* would make the effect silently unselectable (the selected name never resolves).
*/
export function findEffect(
cosmetics: Cosmetics | null | undefined,
effectType: string,
name: string,
): Effect | undefined {
// effects is keyed by the known effectTypes; index it by an arbitrary runtime
// string (a selection/ref may name a type this client doesn't list).
const byType = cosmetics?.effects as
| Record<string, Record<string, Effect>>
| undefined;
const byName = byType?.[effectType];
if (!byName) return undefined;
return byName[name] ?? Object.values(byName).find((e) => e.name === name);
}
export const DefaultPattern = {
name: "default",
patternData: "AAAAAA",
+15
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import {
ColorPaletteSchema,
CosmeticNameSchema,
EffectTypeSchema,
PatternDataSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
@@ -142,6 +143,7 @@ export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type PlayerSkin = z.infer<typeof PlayerSkinSchema>;
export type PlayerEffect = z.infer<typeof PlayerEffectSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
@@ -593,6 +595,8 @@ export const PlayerCosmeticRefsSchema = z.object({
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
skinName: CosmeticNameSchema.optional(),
// At most one selected effect per effectType: key = effectType, value = effect name.
effects: z.record(z.string(), CosmeticNameSchema).optional(),
});
export const PlayerSkinSchema = z.object({
@@ -600,12 +604,23 @@ export const PlayerSkinSchema = z.object({
url: z.string(),
});
// A resolved effect is just an identity: which effect, of which type. Its
// attributes (the visual style) are resolved from the cosmetics catalog by
// (effectType, name), so this needs no per-type variants — a new effectType
// just becomes a new EFFECT_TYPES entry, no change here.
export const PlayerEffectSchema = z.object({
name: CosmeticNameSchema,
effectType: EffectTypeSchema,
});
// Server converts refs to the actual cosmetics here
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
skin: PlayerSkinSchema.optional(),
// Resolved effects keyed by effectType.
effects: z.record(z.string(), PlayerEffectSchema).optional(),
});
export const PlayerSchema = z.object({
+34 -1
View File
@@ -2,7 +2,7 @@ import {
GraphicsOverrides,
GraphicsOverridesSchema,
} from "../../client/render/gl/GraphicsOverrides";
import { Cosmetics } from "../CosmeticSchemas";
import { Cosmetics, EffectType } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
@@ -57,6 +57,7 @@ export const COLOR_KEY = "settings.territoryColor";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const GRAPHICS_KEY = "settings.graphics";
export const EFFECTS_KEY = "settings.effects";
export class UserSettings {
private static cache = new Map<string, string | null>();
@@ -312,6 +313,38 @@ export class UserSettings {
this.removeCached(FLAG_KEY, emitChange);
}
/**
* Selected effect cosmetics, keyed by effectType (at most one per type).
* Persisted as a single JSON blob under EFFECTS_KEY.
*/
getSelectedEffects(): Record<string, string> {
const raw = this.getString(EFFECTS_KEY, "");
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch {
return {};
}
}
getSelectedEffectName(effectType: EffectType): string | null {
return this.getSelectedEffects()[effectType] ?? null;
}
setSelectedEffectName(
effectType: EffectType,
name: string | undefined,
): void {
const map = this.getSelectedEffects();
if (name === undefined) delete map[effectType];
else map[effectType] = name;
if (Object.keys(map).length === 0) this.removeCached(EFFECTS_KEY);
else this.setString(EFFECTS_KEY, JSON.stringify(map));
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
+36 -1
View File
@@ -11,12 +11,13 @@ import {
} from "obscenity";
import countries from "resources/countries.json";
import { Cosmetics } from "../core/CosmeticSchemas";
import { Cosmetics, EffectType, findEffect } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import {
PlayerColor,
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerEffect,
PlayerPattern,
PlayerSkin,
} from "../core/Schemas";
@@ -257,10 +258,44 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { type: "forbidden", reason: "invalid skin: " + message };
}
}
if (refs.effects) {
for (const [type, name] of Object.entries(refs.effects)) {
try {
cosmetics.effects ??= {};
cosmetics.effects[type] = this.isEffectAllowed(
flares,
type as EffectType,
name,
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid effect: " + message };
}
}
}
return { type: "allowed", cosmetics };
}
isEffectAllowed(
flares: string[],
effectType: EffectType,
name: string,
): PlayerEffect {
const found = findEffect(this.cosmetics, effectType, name);
if (!found) throw new Error(`Effect ${name} not found`);
if (
flares.includes("effect:*") ||
flares.includes(`effect:${found.name}`)
) {
return {
name: found.name,
effectType: found.effectType,
};
}
throw new Error(`No flares for effect ${name}`);
}
isSkinAllowed(flares: string[], name: string): PlayerSkin {
const found = this.cosmetics.skins?.[name];
if (!found) throw new Error(`Skin ${name} not found`);
+281
View File
@@ -0,0 +1,281 @@
import {
Cosmetics,
CosmeticsSchema,
EffectSchema,
findEffect,
TransportShipTrailAttributesSchema,
} from "../src/core/CosmeticSchemas";
import { PlayerEffectSchema } from "../src/core/Schemas";
describe("Effect cosmetic schemas", () => {
const base = {
name: "spectrum",
effectType: "transportShipTrail",
product: null,
rarity: "common",
};
describe("TransportShipTrailAttributesSchema (lenient)", () => {
it("parses the known attribute variants", () => {
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "solid",
color: "#f00",
}).success,
).toBe(true);
expect(
TransportShipTrailAttributesSchema.safeParse({ type: "rainbow" })
.success,
).toBe(true);
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "pulse",
color: "#0f0",
}).success,
).toBe(true);
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "gradient",
color: "#f00",
color2: "#00f",
}).success,
).toBe(true);
});
it("tolerates an unknown attribute type (ignored at render time)", () => {
expect(
TransportShipTrailAttributesSchema.safeParse({ type: "sparkle" })
.success,
).toBe(true);
});
it("requires a `type`", () => {
expect(TransportShipTrailAttributesSchema.safeParse({}).success).toBe(
false,
);
});
});
describe("TransportShipTrailAttributesSchema (discriminated styles)", () => {
it("keeps the fields of a known style", () => {
const solid = TransportShipTrailAttributesSchema.parse({
type: "solid",
color: "#f00",
});
expect(solid).toEqual({ type: "solid", color: "#f00" });
const gradient = TransportShipTrailAttributesSchema.parse({
type: "gradient",
color: "#f00",
color2: "#00f",
});
expect(gradient).toEqual({
type: "gradient",
color: "#f00",
color2: "#00f",
});
});
it('normalizes an unrecognized style to { type: "unknown" }', () => {
expect(
TransportShipTrailAttributesSchema.parse({ type: "sparkle" }),
).toEqual({ type: "unknown" });
});
it("normalizes a known style missing required fields to unknown", () => {
// solid without color / gradient without color2 don't match their strict
// variant, so they degrade to the neutral unknown swatch rather than
// failing the parse.
expect(
TransportShipTrailAttributesSchema.parse({ type: "solid" }),
).toEqual({ type: "unknown" });
expect(
TransportShipTrailAttributesSchema.parse({
type: "gradient",
color: "#f00",
}),
).toEqual({ type: "unknown" });
});
});
describe("EffectSchema", () => {
it("parses an effect (discriminated on effectType)", () => {
expect(
EffectSchema.safeParse({
...base,
attributes: { type: "rainbow" },
}).success,
).toBe(true);
});
it("rejects an effect with no attributes", () => {
expect(EffectSchema.safeParse({ ...base }).success).toBe(false);
});
it("rejects an effect with an unknown effectType (no union member)", () => {
expect(
EffectSchema.safeParse({
...base,
effectType: "glow",
attributes: { type: "rainbow" },
}).success,
).toBe(false);
});
it("tolerates an effect with an unknown attribute type", () => {
expect(
EffectSchema.safeParse({
...base,
attributes: { type: "sparkle" },
}).success,
).toBe(true);
});
});
// Exact shape served by the production cosmetics.json: nested
// effects[effectType][effectName], each effect carrying its effectType, and
// extras (e.g. product.priceInCents) stripped.
it("parses the real nested cosmetics.json effects", () => {
const result = CosmeticsSchema.safeParse({
patterns: {},
flags: {},
effects: {
transportShipTrail: {
rainbow_ship: {
name: "rainbow_ship",
effectType: "transportShipTrail",
attributes: { type: "rainbow" },
affiliateCode: null,
product: null,
priceHard: 123,
rarity: "common",
},
gradient: {
name: "gradient",
effectType: "transportShipTrail",
attributes: {
type: "gradient",
color: "#aea2a2",
color2: "#a80000",
},
affiliateCode: null,
product: {
price: "$0.99",
priceInCents: 99,
productId: "prod_x",
priceId: "price_x",
},
rarity: "common",
},
},
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes?.type,
).toBe("rainbow");
}
});
it("tolerates an unknown effectType (outer key) without failing the parse", () => {
const result = CosmeticsSchema.safeParse({
patterns: {},
flags: {},
effects: {
transportShipTrail: {
ship: {
name: "ship",
effectType: "transportShipTrail",
attributes: { type: "solid", color: "#fff" },
product: null,
rarity: "common",
},
},
someFutureEffect: {
thing: {
name: "thing",
attributes: { type: "whatever" },
product: null,
rarity: "common",
},
},
},
});
expect(result.success).toBe(true);
});
});
describe("findEffect", () => {
const effect = (name: string) => ({
name,
attributes: { type: "solid", color: "#fff" } as const,
product: null,
rarity: "common" as const,
});
it("resolves by the catalog object key (the common key === name case)", () => {
const cosmetics = {
effects: { transportShipTrail: { spectrum: effect("spectrum") } },
} as unknown as Cosmetics;
expect(findEffect(cosmetics, "transportShipTrail", "spectrum")?.name).toBe(
"spectrum",
);
});
it("falls back to the name field when the object key differs", () => {
// Catalog key "trail_01" but the effect's name is "spectrum"; selection and
// flares are name-based, so the name must still resolve the effect.
const cosmetics = {
effects: { transportShipTrail: { trail_01: effect("spectrum") } },
} as unknown as Cosmetics;
expect(findEffect(cosmetics, "transportShipTrail", "spectrum")?.name).toBe(
"spectrum",
);
});
it("returns undefined for an unknown effect name", () => {
const cosmetics = {
effects: { transportShipTrail: { spectrum: effect("spectrum") } },
} as unknown as Cosmetics;
expect(
findEffect(cosmetics, "transportShipTrail", "ghost"),
).toBeUndefined();
});
it("returns undefined for an unknown effectType or missing catalog", () => {
const cosmetics = {
effects: { transportShipTrail: { spectrum: effect("spectrum") } },
} as unknown as Cosmetics;
expect(findEffect(cosmetics, "wrongType", "spectrum")).toBeUndefined();
expect(findEffect(null, "transportShipTrail", "spectrum")).toBeUndefined();
expect(
findEffect({} as Cosmetics, "transportShipTrail", "x"),
).toBeUndefined();
});
});
describe("PlayerEffectSchema (identity: name + effectType)", () => {
it("parses a name + effectType (attributes live in the catalog)", () => {
expect(
PlayerEffectSchema.safeParse({
name: "spectrum",
effectType: "transportShipTrail",
}).success,
).toBe(true);
});
it("rejects an unknown effectType (not in EFFECT_TYPES)", () => {
expect(
PlayerEffectSchema.safeParse({
name: "spectrum",
effectType: "glow",
}).success,
).toBe(false);
});
it("requires an effectType", () => {
expect(PlayerEffectSchema.safeParse({ name: "spectrum" }).success).toBe(
false,
);
});
});
+142
View File
@@ -88,6 +88,45 @@ const skinChecker = new PrivilegeCheckerImpl(
bannedWords,
);
const effectCosmetics = {
patterns: {},
colorPalettes: {},
flags: {},
effects: {
// Each effect carries its effectType field (matching the outer key), as the
// schema requires.
transportShipTrail: {
spectrum: {
name: "spectrum",
effectType: "transportShipTrail" as const,
attributes: { type: "rainbow" } as const,
url: "",
affiliateCode: null,
product: null,
priceSoft: undefined,
priceHard: undefined,
rarity: "legendary",
},
crimson: {
name: "crimson",
effectType: "transportShipTrail" as const,
attributes: { type: "solid", color: "#e01b24" } as const,
url: "",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
priceSoft: undefined,
priceHard: undefined,
rarity: "common",
},
},
},
};
const effectChecker = new PrivilegeCheckerImpl(
effectCosmetics,
mockDecoder,
bannedWords,
);
describe("UsernameCensor", () => {
describe("isProfane (via matcher.hasMatch)", () => {
test("detects exact banned words", () => {
@@ -521,6 +560,109 @@ describe("Skin validation", () => {
});
});
describe("Effect validation in isAllowed", () => {
test("allows valid effect with wildcard flare", () => {
const result = effectChecker.isAllowed(["effect:*"], {
effects: { transportShipTrail: "spectrum" },
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.effects?.transportShipTrail).toEqual({
name: "spectrum",
effectType: "transportShipTrail",
});
}
});
test("allows valid effect with exact-match flare", () => {
const result = effectChecker.isAllowed(["effect:crimson"], {
effects: { transportShipTrail: "crimson" },
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.effects?.transportShipTrail).toEqual({
name: "crimson",
effectType: "transportShipTrail",
});
}
});
test("rejects effect when user lacks flare", () => {
const result = effectChecker.isAllowed([], {
effects: { transportShipTrail: "spectrum" },
});
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/invalid effect/);
}
});
test("rejects effect under an unknown effectType key", () => {
const result = effectChecker.isAllowed(["effect:*"], {
effects: { wrongType: "spectrum" },
});
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/Effect spectrum not found/);
}
});
test("rejects nonexistent effect", () => {
const result = effectChecker.isAllowed(["effect:*"], {
effects: { transportShipTrail: "ghost" },
});
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/Effect ghost not found/);
}
});
test("no effects in refs leaves cosmetics.effects undefined", () => {
const result = effectChecker.isAllowed(["effect:*"], {});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.effects).toBeUndefined();
}
});
test("resolves an effect whose catalog key differs from its name", () => {
// Catalog key "trail_01" but name "spectrum"; selection/flares are
// name-based, so the name must still resolve and validate.
const checker = new PrivilegeCheckerImpl(
{
patterns: {},
colorPalettes: {},
flags: {},
effects: {
transportShipTrail: {
trail_01: {
name: "spectrum",
effectType: "transportShipTrail" as const,
attributes: { type: "rainbow" } as const,
url: "",
affiliateCode: null,
product: null,
rarity: "legendary",
},
},
},
},
mockDecoder,
bannedWords,
);
const result = checker.isAllowed(["effect:spectrum"], {
effects: { transportShipTrail: "spectrum" },
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.effects?.transportShipTrail).toEqual({
name: "spectrum",
effectType: "transportShipTrail",
});
}
});
});
describe("PrivilegeCheckerImpl#resolveClanTag", () => {
// Reserved tags are stored uppercase, exactly as PrivilegeRefresher loads them.
const makeChecker = (reservedTags: string[]) =>
+48
View File
@@ -0,0 +1,48 @@
import { EFFECTS_KEY, UserSettings } from "../src/core/game/UserSettings";
describe("UserSettings effect selection", () => {
beforeEach(() => {
localStorage.clear();
// UserSettings keeps a static in-memory cache; reset it too so each test
// reads fresh from the (cleared) localStorage.
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("sets and reads a per-effectType selection", () => {
const s = new UserSettings();
s.setSelectedEffectName("transportShipTrail", "spectrum");
expect(s.getSelectedEffectName("transportShipTrail")).toBe("spectrum");
});
it("returns null when nothing is selected", () => {
expect(
new UserSettings().getSelectedEffectName("transportShipTrail"),
).toBeNull();
});
it("clearing the last selection removes the storage key", () => {
const s = new UserSettings();
s.setSelectedEffectName("transportShipTrail", "spectrum");
s.setSelectedEffectName("transportShipTrail", undefined);
expect(s.getSelectedEffectName("transportShipTrail")).toBeNull();
expect(localStorage.getItem(EFFECTS_KEY)).toBeNull();
});
it("clearing one effectType leaves other types intact", () => {
const s = new UserSettings();
// Seed two types directly (only one real effectType exists today).
localStorage.setItem(
EFFECTS_KEY,
JSON.stringify({ transportShipTrail: "spectrum", future: "x" }),
);
s.setSelectedEffectName("transportShipTrail", undefined);
expect(s.getSelectedEffects()).toEqual({ future: "x" });
});
it("returns an empty map for a corrupt blob", () => {
localStorage.setItem(EFFECTS_KEY, "not json");
expect(new UserSettings().getSelectedEffects()).toEqual({});
});
});