Files
OpenFrontIO/tests/CosmeticSchemas.test.ts
T
Evan bd9ef9a317 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>
2026-06-29 13:13:48 -07:00

282 lines
8.1 KiB
TypeScript

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,
);
});
});