diff --git a/index.html b/index.html index 4af07ad2d..14d1df331 100644 --- a/index.html +++ b/index.html @@ -272,6 +272,11 @@ inline class="hidden w-full h-full page-content relative z-50" > + { } } + // 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 = {}; + 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 { } } + if (refs.effects && cosmetics) { + const effects: Record = {}; + 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; } diff --git a/src/client/EffectsInput.ts b/src/client/EffectsInput.ts new file mode 100644 index 000000000..3285c562f --- /dev/null +++ b/src/client/EffectsInput.ts @@ -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 { + 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` + ${translateText("effects.title")} + ` + : html`${renderTransportShipTrailSwatch(this.trailAttributes)}`; + + return html` + + `; + } +} diff --git a/src/client/EffectsModal.ts b/src/client/EffectsModal.ts new file mode 100644 index 000000000..f5aa0591d --- /dev/null +++ b/src/client/EffectsModal.ts @@ -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) => { + 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({}); + }); +});