) => {
+ this.onUserMe(event.detail);
+ },
+ );
+ }
+
+ async onUserMe(userMeResponse: UserMeResponse | false) {
+ this.userMeResponse = userMeResponse;
+ this.cosmetics = await fetchCosmetics();
+ this.requestUpdate();
+ }
+
+ protected renderHeaderSlot() {
+ return html`
+
+ ${modalHeader({
+ title: translateText("effects.title"),
+ onBack: () => this.close(),
+ ariaLabel: translateText("common.back"),
+ rightContent: html``,
+ })}
+
+ `;
+ }
+
+ protected renderBody() {
+ return html`
+
+
+ {
+ this.close();
+ window.showPage?.("page-item-store");
+ }}
+ >
+
+
+
+ `;
+ }
+}
diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts
index 97d369d68..665a2c06c 100644
--- a/src/client/LangSelector.ts
+++ b/src/client/LangSelector.ts
@@ -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",
];
diff --git a/src/client/Main.ts b/src/client/Main.ts
index b3dabb9ad..d5e861e13 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -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",
diff --git a/src/client/Store.ts b/src/client/Store.ts
index d2ba99006..8109ef501 100644
--- a/src/client/Store.ts
+++ b/src/client/Store.ts
@@ -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 ),
+ // matching the home selection modal.
+ return html``;
+ }
+
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",
);
diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts
index 871ad74db..d2e7cdcee 100644
--- a/src/client/components/CosmeticButton.ts
+++ b/src/client/components/CosmeticButton.ts
@@ -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`
+ ${translateText("territory_patterns.pattern.default")}
+
`;
+ }
+ // 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;
diff --git a/src/client/components/EffectPreview.ts b/src/client/components/EffectPreview.ts
new file mode 100644
index 000000000..21b78eb33
--- /dev/null
+++ b/src/client/components/EffectPreview.ts
@@ -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``;
+ case "gradient":
+ return html``;
+ case "pulse":
+ return html``;
+ case "solid":
+ return html``;
+ default:
+ // Unknown / unrecognized style — neutral swatch.
+ return html``;
+ }
+}
diff --git a/src/client/components/EffectsGrid.ts b/src/client/components/EffectsGrid.ts
new file mode 100644
index 000000000..9b5d391a4
--- /dev/null
+++ b/src/client/components/EffectsGrid.ts
@@ -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``;
+ }
+ 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` this.select(effectType, name)}
+ >`;
+ }
+
+ 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`
+ ${translateText("store.no_effects")}
+
`;
+ }
+
+ return html`
+
+ ${sections.map(
+ (s) => html`
+
+
+ ${translateText(`effects.type.${s.type}`)}
+
+
+ ${s.items.map((r) => this.renderTile(s.type, r))}
+
+
+ `,
+ )}
+
+ `;
+ }
+}
diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts
index ef33aa100..13be0de92 100644
--- a/src/client/components/PlayPage.ts
+++ b/src/client/components/PlayPage.ts
@@ -96,6 +96,10 @@ export class PlayPage extends LitElement {
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
>
+
@@ -111,6 +115,10 @@ export class PlayPage extends LitElement {
show-select-label
class="flex-1 h-full"
>
+
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index 704fc61a3..8b84eacff 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -9,6 +9,13 @@ export type Flag = z.infer;
export type Skin = z.infer;
export type Pack = z.infer;
export type Subscription = z.infer;
+// An effect cosmetic of any type — discriminated on effectType (today only
+// transportShipTrail; gains a member per effectType).
+export type Effect = z.infer;
+export type EffectType = z.infer;
+export type TransportShipTrailAttributes = z.infer<
+ typeof TransportShipTrailAttributesSchema
+>;
export type PatternName = z.infer;
export type Product = z.infer;
export type ColorPalette = z.infer;
@@ -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>
+ | 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",
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 6c122050f..2f07023d3 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -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;
export type PlayerPattern = z.infer;
export type PlayerColor = z.infer;
export type PlayerSkin = z.infer;
+export type PlayerEffect = z.infer;
export type GameStartInfo = z.infer;
export type GameInfo = z.infer;
export type PublicGames = z.infer;
@@ -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({
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index 8b7f730a0..96bbf933e 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -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 {
@@ -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();
@@ -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 {
+ 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);
}
diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts
index c31da062e..c11f4df6f 100644
--- a/src/server/Privilege.ts
+++ b/src/server/Privilege.ts
@@ -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`);
diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts
new file mode 100644
index 000000000..068a0904f
--- /dev/null
+++ b/tests/CosmeticSchemas.test.ts
@@ -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,
+ );
+ });
+});
diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts
index 5fc8e753d..8626993eb 100644
--- a/tests/Privilege.test.ts
+++ b/tests/Privilege.test.ts
@@ -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[]) =>
diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts
new file mode 100644
index 000000000..c475b6255
--- /dev/null
+++ b/tests/UserSettings.test.ts
@@ -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 }
+ ).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({});
+ });
+});