Files
OpenFrontIO/tests/CosmeticSchemas.test.ts
T
2794ab1270 feat: nuke-trail cosmetic effect + tabbed effects picker (#4466)
## What

Adds a **`nukeTrail`** cosmetic effectType alongside
`transportShipTrail`, so nukes leave a trail colored by their own
gradient/transition effect — independent of the boat-trail effect (a
player can run both). Also reorganizes the effects picker and store into
per-effectType **tabs**.

## Rendering

Boat and nuke trails are stamped into **one** trail texture keyed only
by owner, so independent coloring needs a per-tile unit-class signal:

- **Trail texture** `R8UI` → `R16UI`: texel = `ownerID(bits 0-11) |
nukeBit(bit 12)`. `TrailManager` stamps the bit (and preserves it when
repainting on unit death); the `Uint8Array`→`Uint16Array` ripple +
`UNSIGNED_SHORT` uploads flow through `GpuResources`, `TrailPass`,
`Upload`, `MapRenderer`, `Renderer`, `FrameData`.
- **Effect texture** widened to two stacked blocks
(`TRAIL_EFFECT_BLOCKS`): rows 0–7 = transportShipTrail, rows 8–15 =
nukeTrail. `writeEffectEntry(…, rowBase)`; `syncPlayerEffects` resolves
both effectTypes.
- **Shader** masks the owner, derives `rowBase` from the nuke bit,
offsets every row, and reuses the gradient/transition decode.
- Bonus: the 12-bit owner mask lifts the old `R8UI` >255-player
truncation.

## Schema / server / UI

- Shared attributes schema renamed `TransportShipTrail…` →
**`TrailEffectAttributesSchema`** (it's no longer ship-specific);
`NukeTrailEffectSchema` added to `EffectSchema` +
`CosmeticsSchema.effects`. `EFFECT_TYPES = [transportShipTrail,
nukeTrail]`.
- Server `Privilege`, selection, and the picker grid all iterate
`EFFECT_TYPES`, so they handle the new type with **no per-type code**.
- **Tabs:** the selection modal uses one tab per effectType
(`BaseModal`'s native tabs); the **store's** EFFECTS panel gets an
internal sub-tab bar (its top-level PACKS/EFFECTS tabs can't nest). Tabs
are always present, so a type you own entirely still appears as an empty
tab (previously the boat-trail section vanished from the store when you
owned everything).

## Review

A 3-angle adversarial review (bit-packing, type-ripple, GLSL/data-flow)
**refuted** the correctness concerns — the R16UI format, masking, and
block layout agree across `TrailManager` / shader / builder. The minor
survivors (a preview that only resolved boat trails, stale comments)
were fixed.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` — all clean.
- Schema/`Privilege` tests updated for `nukeTrail` (96 tests pass).
- The GL trail + tab UI are visual — not yet verified in a running game.
- The catalog (`cosmetics.json`, closed-source API) must ship the
`effects.nukeTrail` block for the effect to appear in production.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:13:41 -07:00

376 lines
11 KiB
TypeScript

import {
Cosmetics,
CosmeticsSchema,
EffectSchema,
findEffect,
TrailEffectAttributesSchema,
} 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("TrailEffectAttributesSchema", () => {
it("parses a gradient with a color list, colorSize, and movementSpeed", () => {
const parsed = TrailEffectAttributesSchema.parse({
type: "gradient",
colors: ["#f00", "#00f"],
colorSize: 16,
movementSpeed: 0.15,
});
expect(parsed).toEqual({
type: "gradient",
colors: ["#f00", "#00f"],
colorSize: 16,
movementSpeed: 0.15,
});
});
it("accepts a single-color list (solid) and an empty list", () => {
expect(
TrailEffectAttributesSchema.safeParse({
type: "gradient",
colors: ["#f00"],
colorSize: 16,
movementSpeed: 0.15,
}).success,
).toBe(true);
expect(
TrailEffectAttributesSchema.safeParse({
type: "gradient",
colors: [],
colorSize: 16,
movementSpeed: 0.15,
}).success,
).toBe(true);
});
it("requires the gradient type, colors, colorSize, and movementSpeed", () => {
// Unrecognized styles (no discriminated-union member) are rejected.
expect(
TrailEffectAttributesSchema.safeParse({ type: "solid" }).success,
).toBe(false);
// colors, colorSize, and movementSpeed are all required.
expect(
TrailEffectAttributesSchema.safeParse({
type: "gradient",
colors: ["#f00"],
}).success,
).toBe(false);
expect(TrailEffectAttributesSchema.safeParse({}).success).toBe(false);
});
it("parses a transition with a color list and frequency", () => {
const parsed = TrailEffectAttributesSchema.parse({
type: "transition",
colors: ["#002aff", "#4805ff"],
frequency: 1,
});
expect(parsed).toEqual({
type: "transition",
colors: ["#002aff", "#4805ff"],
frequency: 1,
});
});
it("requires frequency for a transition", () => {
expect(
TrailEffectAttributesSchema.safeParse({
type: "transition",
colors: ["#002aff", "#4805ff"],
}).success,
).toBe(false);
});
});
describe("EffectSchema", () => {
it("parses an effect (discriminated on effectType)", () => {
expect(
EffectSchema.safeParse({
...base,
attributes: {
type: "gradient",
colors: ["#f00", "#0f0", "#00f"],
colorSize: 16,
movementSpeed: 0.15,
},
}).success,
).toBe(true);
});
it("parses a nukeTrail effect (same attributes, different effectType)", () => {
expect(
EffectSchema.safeParse({
...base,
name: "tiel_red_gradient_nuke_trail",
effectType: "nukeTrail",
attributes: {
type: "gradient",
colors: ["#ff0000", "#00ffb3"],
colorSize: 0.5,
movementSpeed: 2,
},
}).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: "gradient",
colors: ["#f00"],
colorSize: 16,
movementSpeed: 0.15,
},
}).success,
).toBe(false);
});
it("rejects an effect with a non-gradient attribute type", () => {
expect(
EffectSchema.safeParse({
...base,
attributes: { type: "sparkle" },
}).success,
).toBe(false);
});
});
// 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: "gradient",
colors: ["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"],
colorSize: 24,
movementSpeed: 0.2,
},
affiliateCode: null,
product: null,
priceHard: 123,
rarity: "common",
},
gradient: {
name: "gradient",
effectType: "transportShipTrail",
attributes: {
type: "gradient",
colors: ["#aea2a2", "#a80000"],
colorSize: 16,
movementSpeed: 0.15,
},
affiliateCode: null,
product: {
price: "$0.99",
priceInCents: 99,
productId: "prod_x",
priceId: "price_x",
},
rarity: "common",
},
},
nukeTrail: {
tiel_red_gradient_nuke_trail: {
name: "tiel_red_gradient_nuke_trail",
effectType: "nukeTrail",
attributes: {
type: "gradient",
colors: ["#ff0000", "#00ffb3"],
colorSize: 0.5,
movementSpeed: 2,
},
affiliateCode: null,
product: null,
priceHard: 1,
rarity: "common",
},
},
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes
?.colors,
).toEqual(["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"]);
expect(
result.data.effects?.nukeTrail?.tiel_red_gradient_nuke_trail
?.effectType,
).toBe("nukeTrail");
}
});
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: "gradient",
colors: ["#fff"],
colorSize: 16,
movementSpeed: 0.15,
},
product: null,
rarity: "common",
},
},
someFutureEffect: {
thing: {
name: "thing",
attributes: { type: "whatever" },
product: null,
rarity: "common",
},
},
},
});
expect(result.success).toBe(true);
});
it("drops a newer-shaped effect within a known effectType without failing the catalog", () => {
const result = CosmeticsSchema.safeParse({
patterns: {},
flags: {},
effects: {
transportShipTrail: {
good: {
name: "good",
effectType: "transportShipTrail",
attributes: {
type: "gradient",
colors: ["#fff"],
colorSize: 16,
movementSpeed: 0.15,
},
product: null,
rarity: "common",
},
// A newer effect shape this client doesn't understand yet — must be
// dropped, not fail the whole catalog parse.
future: {
name: "future",
effectType: "transportShipTrail",
attributes: { type: "hologram", intensity: 3 },
product: null,
rarity: "common",
},
},
},
});
expect(result.success).toBe(true);
if (result.success) {
const trails = result.data.effects?.transportShipTrail;
// The good effect survives...
expect(trails?.good?.name).toBe("good");
// ...and only the unparseable newer one is dropped.
expect(trails?.future).toBeUndefined();
}
});
});
describe("findEffect", () => {
const effect = (name: string) => ({
name,
attributes: {
type: "gradient",
colors: ["#fff"],
colorSize: 16,
movementSpeed: 0.15,
} 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,
);
});
});