mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 12:12:15 +00:00
bd9ef9a317
## 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>
282 lines
8.1 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|