mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-14 12:14:31 +00:00
## 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>
376 lines
11 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|