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
co-authored by Claude Opus 4.8
parent ccd0745ad4
commit bd9ef9a317
20 changed files with 1198 additions and 10 deletions
+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);
}