feat: render transport-ship trail cosmetic as a gradient (#4454)

## What

Renders the `transportShipTrail` cosmetic effect in-game. Transport
ships already left a trail, but it was always drawn in the player's
**territory color** — this wires the selected effect through to the
renderer so the trail shows the player's chosen **gradient**.

## How

- **Per-player effect texture** (`RGBA32F`, mirrors the palette texture)
keyed by `smallID`, sampled by the trail fragment shader. Each row holds
a gradient color; spare alpha channels carry the color count,
`colorSize`, and `movementSpeed`.
- **Shader**
([trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl))
cycles a flowing gradient through the color list: 1 color → flat, 2+ →
animated bands scrolling along the trail. No effect (count 0) falls back
to the territory color; alt-view keeps affiliation colors.
- **WebGLFrameBuilder** resolves each player's catalog attributes (the
in-game cosmetic is only `{ name, effectType }`; the style/colors live
in the catalog) and encodes them. Resolution is decoupled from the
first-seen palette path so it retries until the catalog loads, and
unparseable colors are dropped so bad catalog data degrades to the
territory color rather than rendering black.

## Schema

Collapses the trail attributes to a single gradient shape:

```ts
{ type: "gradient", colors: string[], colorSize: number, movementSpeed: number }
```

- `colors` — solid = one color, rainbow = the spectrum, gradient = two
or more.
- `colorSize` — band width (tiles per color band; `1` is the default, ~4
tiles).
- `movementSpeed` — scroll rate along the trail (tiles/sec; `0` =
static).

## Notes

- Animation is render-only (local time), no simulation/determinism
impact.
- The catalog (`cosmetics.json`, served by the closed-source API) must
ship effects in this `{ type: "gradient", colors, colorSize,
movementSpeed }` shape.
- Band thickness (`4.0` base in the shader) and the gradient frequency
are visual constants picked without in-game verification — easy to tune.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema + Privilege test suites updated for the gradient shape (92
tests pass).
- Not yet visually verified in a running game (effect selection is
flare-gated).

🤖 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 20:28:47 -07:00
committed by GitHub
parent f4b47ce06c
commit 7c151e76ad
11 changed files with 373 additions and 146 deletions
+89 -1
View File
@@ -1,12 +1,20 @@
import { Colord } from "colord";
import { Colord, colord } from "colord";
import { base64url } from "jose";
import { assetUrl } from "../core/AssetUrls";
import {
findEffect,
type TransportShipTrailAttributes,
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
// Value import from the leaf module (not the ./render/gl barrel) so non-Vite
// consumers don't pull in GPURenderer and its shaders — see note above.
import { MAX_TRAIL_COLORS } from "./render/gl/utils/ColorUtils";
import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
@@ -24,10 +32,21 @@ const PALETTE_SIZE = 4096;
*/
export class WebGLFrameBuilder {
private readonly palette: Float32Array;
// Per-player transport-ship-trail gradient, keyed by smallID. Layout is
// 4096×MAX_TRAIL_COLORS: row 0 = (color0.rgb, colorCount), row r = (colorR.rgb).
// Consumed by TrailPass's effect texture.
private readonly effectPalette: Float32Array;
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;
private readonly knownSmallIDs = new Set<number>();
/**
* smallIDs whose trail effect has been resolved into the effect palette.
* Separate from knownSmallIDs because effect resolution depends on the
* cosmetics catalog, which may not be loaded the tick a player is first seen
* — keeping it separate lets us retry next tick instead of skipping forever.
*/
private readonly effectResolved = new Set<number>();
/**
* Last spawn tile pushed to the renderer per smallID. Players can re-pick
* spawn during the spawn phase, so this tracks the latest value rather than
@@ -45,6 +64,7 @@ export class WebGLFrameBuilder {
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
this.effectPalette = new Float32Array(PALETTE_SIZE * MAX_TRAIL_COLORS * 4);
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
}
@@ -52,6 +72,7 @@ export class WebGLFrameBuilder {
/** Drop internal caches to force a full re-upload of state on the next update(). */
clearCaches(): void {
this.knownSmallIDs.clear();
this.effectResolved.clear();
this.lastSpawnTile.clear();
this.localPlayerSmallID = 0;
this.skinsInitialized = false;
@@ -85,6 +106,7 @@ export class WebGLFrameBuilder {
update(gameView: GameView): void {
this.syncPlayers(gameView);
this.syncPlayerEffects(gameView);
this.syncPlayerSpawns(gameView);
this.syncLocalPlayer(gameView);
this.syncSpawnOverlay(gameView);
@@ -254,6 +276,72 @@ export class WebGLFrameBuilder {
}
}
/**
* Resolve each player's transport-ship-trail effect into the effect palette.
* A player's resolved cosmetic is just { name, effectType }; the style and
* colors live in the catalog, so we look them up via the cached cosmetics.
* Decoupled from syncPlayers' first-seen guard: if the catalog isn't loaded
* yet we leave the player unresolved and retry next tick (the trail keeps its
* territory color meanwhile). Re-uploads the effect texture only when a
* recognized style was actually written.
*/
private syncPlayerEffects(gameView: GameView): void {
const catalog = getCachedCosmetics();
if (!catalog) return; // Catalog not loaded yet — retry on a later tick.
let dirty = false;
for (const p of gameView.players()) {
const smallID = p.smallID();
if (this.effectResolved.has(smallID)) continue;
this.effectResolved.add(smallID);
const trailEffect = p.cosmetics.effects?.["transportShipTrail"];
if (!trailEffect) continue; // No effect — nothing to write or upload.
const effect = findEffect(
catalog,
"transportShipTrail",
trailEffect.name,
);
if (effect?.effectType !== "transportShipTrail") continue;
if (this.writeEffectEntry(smallID, effect.attributes)) dirty = true;
}
if (dirty) this.view.updateEffectPalette(this.effectPalette);
}
/**
* Encode a player's transport-ship-trail gradient into the effect palette.
* Layout matches trail.frag.glsl: row r holds color r's rgb, and the spare
* alpha channels carry the gradient's scalar params — row 0's alpha = color
* count (0 → the shader falls back to the territory color), row 1's alpha =
* colorSize (band width), row 2's alpha = movementSpeed (scroll rate).
* colord doesn't throw on a bad color string (it returns black), so unparseable
* colors are dropped — leaving an empty list, which falls back to the territory
* color rather than rendering black. Returns whether any color was written.
*/
private writeEffectEntry(
smallID: number,
attrs: TransportShipTrailAttributes,
): boolean {
const colors = attrs.colors
.map((s) => colord(s))
.filter((c) => c.isValid())
.slice(0, MAX_TRAIL_COLORS)
.map((c) => c.toRgb());
for (let r = 0; r < MAX_TRAIL_COLORS; r++) {
const off = (r * PALETTE_SIZE + smallID) * 4;
const c = colors[r] ?? { r: 0, g: 0, b: 0 };
this.effectPalette[off] = c.r / 255;
this.effectPalette[off + 1] = c.g / 255;
this.effectPalette[off + 2] = c.b / 255;
this.effectPalette[off + 3] = 0;
}
// Scalar params packed into spare alpha channels (rows 02 always exist).
this.effectPalette[(0 * PALETTE_SIZE + smallID) * 4 + 3] = colors.length;
this.effectPalette[(1 * PALETTE_SIZE + smallID) * 4 + 3] = attrs.colorSize;
this.effectPalette[(2 * PALETTE_SIZE + smallID) * 4 + 3] =
attrs.movementSpeed;
return colors.length > 0;
}
private writePaletteEntry(
smallID: number,
fill: Colord,
+16 -37
View File
@@ -1,48 +1,27 @@
import { html, TemplateResult } from "lit";
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
// A flowing spectrum used for the "rainbow" transport-ship-trail preview.
const RAINBOW_GRADIENT =
"linear-gradient(90deg,#ff0000,#ff8a00,#ffe600,#28c76f,#00a8ff,#7d5fff,#ff0000)";
// Neutral fallback for attribute types we don't recognize.
const UNKNOWN_BG = "#444";
// Neutral fallback when a trail has no usable colors.
const EMPTY_BG = "#444";
/**
* Render a swatch preview of a transport-ship-trail's attributes, filling its
* container: solid = flat color, pulse = same color pulsing, rainbow = full
* spectrum, gradient = two-color blend. Unknown attribute types render a neutral
* swatch (we ignore types we don't know about).
* container. A trail is a list of colors: one color renders as a flat swatch,
* two or more as a left-to-right gradient (a multi-color list reads as a
* rainbow). An empty list renders a neutral swatch.
*/
export function renderTransportShipTrailSwatch(
attributes: TransportShipTrailAttributes,
): TemplateResult {
switch (attributes.type) {
case "rainbow":
return html`<div
class="w-full h-full rounded-md"
style="background:${RAINBOW_GRADIENT};"
></div>`;
case "gradient":
return html`<div
class="w-full h-full rounded-md"
style="background:linear-gradient(90deg,${attributes.color},${attributes.color2});"
></div>`;
case "pulse":
return html`<div
class="w-full h-full rounded-md animate-pulse"
style="background:${attributes.color};"
></div>`;
case "solid":
return html`<div
class="w-full h-full rounded-md"
style="background:${attributes.color};"
></div>`;
default:
// Unknown / unrecognized style — neutral swatch.
return html`<div
class="w-full h-full rounded-md"
style="background:${UNKNOWN_BG};"
></div>`;
}
const colors = attributes.colors;
const background =
colors.length === 0
? EMPTY_BG
: colors.length === 1
? colors[0]
: `linear-gradient(90deg,${colors.join(",")})`;
return html`<div
class="w-full h-full rounded-md"
style="background:${background};"
></div>`;
}
+3
View File
@@ -132,6 +132,9 @@ export class MapRenderer {
updatePalette(paletteData: Float32Array): void {
this.renderer?.updatePalette(paletteData);
}
updateEffectPalette(effectData: Float32Array): void {
this.renderer?.updateEffectPalette(effectData);
}
addPlayers(
players: PlayerStatic[],
paletteData: Float32Array,
+38 -2
View File
@@ -59,7 +59,7 @@ import { UnitPass } from "./passes/UnitPass";
import { WorldTextPass } from "./passes/WorldTextPass";
import type { RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation";
import { getPaletteSize, hexToRgb } from "./utils/ColorUtils";
import { getPaletteSize, hexToRgb, MAX_TRAIL_COLORS } from "./utils/ColorUtils";
import { renderDpr } from "./utils/Dpr";
import {
createTexture2D,
@@ -132,6 +132,10 @@ export class GPURenderer {
private paletteTex: WebGLTexture;
private paletteData: Float32Array;
// Per-player transport-ship-trail gradient, keyed by smallID (RGBA32F,
// 4096×MAX_TRAIL_COLORS): row r = color r's rgb; row 0's alpha = color count.
// Sampled by TrailPass.
private effectTex: WebGLTexture;
private patternMetaTex: WebGLTexture;
private patternDataTex: WebGLTexture;
private skinAtlas: SkinAtlasArray;
@@ -235,6 +239,18 @@ export class GPURenderer {
filter: gl.NEAREST,
});
// Per-player trail-effect texture (one row per gradient color). Starts zeroed
// (color count 0 everywhere = no effect → trail uses territory color).
this.effectTex = createTexture2D(gl, {
width: palW,
height: MAX_TRAIL_COLORS,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: new Float32Array(palW * MAX_TRAIL_COLORS * 4),
filter: gl.NEAREST,
});
this.patternMetaTex = createTexture2D(gl, {
width: palW,
height: 1,
@@ -371,13 +387,14 @@ export class GPURenderer {
this.settings.spawnOverlay,
);
// --- Trail (needs trailTex, paletteTex) ---
// --- Trail (needs trailTex, paletteTex, effectTex) ---
this.trailPass = new TrailPass(
gl,
mapW,
mapH,
this.res.trailTex,
this.paletteTex,
this.effectTex,
this.settings,
);
@@ -629,6 +646,24 @@ export class GPURenderer {
this.namePass.refreshPlayerColors(this.paletteData);
}
/** Re-upload the per-player trail-effect texture (style + colors by smallID). */
updateEffectPalette(effectData: Float32Array): void {
const gl = this.gl;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
MAX_TRAIL_COLORS,
gl.RGBA,
gl.FLOAT,
effectData,
);
}
/** Register late-arriving players (updates palette + NamePass lookup maps). */
addPlayers(
players: PlayerStatic[],
@@ -1228,6 +1263,7 @@ export class GPURenderer {
this.barPass.dispose();
disposeGPUResources(this.gl, this.res);
this.gl.deleteTexture(this.paletteTex);
this.gl.deleteTexture(this.effectTex);
this.gl.deleteTexture(this.patternMetaTex);
this.gl.deleteTexture(this.patternDataTex);
this.gl.deleteTexture(this.skinLayerTex);
+5 -1
View File
@@ -10,7 +10,11 @@ export { applyGraphicsOverrides } from "./RenderOverrides";
export { createRenderSettings, dumpSettings } from "./RenderSettings";
export type { RenderSettings } from "./RenderSettings";
export { deepAssign, deepDiff } from "./SettingsUtils";
export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
export {
MAX_TRAIL_COLORS,
buildTerrainRGBA,
getPaletteSize,
} from "./utils/ColorUtils";
export { renderDpr } from "./utils/Dpr";
export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory";
export type { SAMInfo } from "./utils/NukeTrajectory";
+12
View File
@@ -24,13 +24,18 @@ export class TrailPass {
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uTrailAlpha: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private trailTex: WebGLTexture;
private paletteTex: WebGLTexture;
private effectTex: WebGLTexture;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
// Anchor animation time at construction (like NukeTelegraphPass/SamRadiusPass)
// so the value stays small and sin()/fract() don't quantize over long sessions.
private readonly startTime = performance.now();
/** CPU-side trail state (R8UI, 0=none, 1255=ownerID). */
private cpuTrailState: Uint8Array;
@@ -49,6 +54,7 @@ export class TrailPass {
mapH: number,
trailTex: WebGLTexture,
paletteTex: WebGLTexture,
effectTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
@@ -57,6 +63,7 @@ export class TrailPass {
this.mapH = mapH;
this.trailTex = trailTex;
this.paletteTex = paletteTex;
this.effectTex = effectTex;
this.cpuTrailState = new Uint8Array(mapW * mapH);
this.program = createProgram(
@@ -70,12 +77,14 @@ export class TrailPass {
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uTrailAlpha = gl.getUniformLocation(this.program, "uTrailAlpha")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTrailTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uEffect"), 3);
this.vao = createMapQuad(gl, mapW, mapH);
}
@@ -168,6 +177,7 @@ export class TrailPass {
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha);
gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
@@ -178,6 +188,8 @@ export class TrailPass {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
@@ -5,8 +5,13 @@ precision highp usampler2D;
uniform usampler2D uTrailTex; // R8UI — trail ownerID per cell (0 = none)
uniform sampler2D uPalette; // RGBA32F — player colors
uniform sampler2D uAffiliation; // RGBA8 — affiliation colors (row 0 = border, row 1 = unit)
uniform sampler2D uEffect; // RGBA32F — trail gradient, keyed by ownerID:
// row r = color r's rgb; spare alphas hold scalars:
// row 0.a = color count (0 = no effect → territory color),
// row 1.a = colorSize (band width), row 2.a = movementSpeed
uniform vec2 uMapSize;
uniform float uTrailAlpha;
uniform float uTime; // seconds, for the flowing gradient animation
uniform int uAltView;
in vec2 vWorldPos;
@@ -22,10 +27,37 @@ void main() {
vec3 color;
if (uAltView != 0) {
// Alt view recolors everything by affiliation — effects stay off so the
// strategic overlay reads consistently.
color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb;
} else {
float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
color = texture(uPalette, vec2(u, 0.25)).rgb;
int owner = int(trailOwner);
int count = int(texelFetch(uEffect, ivec2(owner, 0), 0).a + 0.5);
if (count <= 0) {
// No effect — fall back to the player's territory color.
float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
color = texture(uPalette, vec2(u, 0.25)).rgb;
} else if (count == 1) {
// Single color — flat trail.
color = texelFetch(uEffect, ivec2(owner, 0), 0).rgb;
} else {
// Multiple colors — cyclic gradient banded across the map (world-space
// diagonal), scrolling over time so a moving trail shifts hue along it.
// colorSize scales the band width (colorSize = 1 is the default size, ~4
// tiles per band); movementSpeed = tiles/sec the bands travel.
float colorSize = max(texelFetch(uEffect, ivec2(owner, 1), 0).a, 0.001);
float movementSpeed = texelFetch(uEffect, ivec2(owner, 2), 0).a;
// 4.0 = tiles per band at colorSize 1; tune for default band thickness.
float cycle = colorSize * 4.0 * float(count);
float phase =
fract((vWorldPos.x + vWorldPos.y - uTime * movementSpeed) / cycle);
float f = phase * float(count);
int i = int(f) % count;
int j = (i + 1) % count;
vec3 a = texelFetch(uEffect, ivec2(owner, i), 0).rgb;
vec3 b = texelFetch(uEffect, ivec2(owner, j), 0).rgb;
color = mix(a, b, fract(f));
}
}
fragColor = vec4(color, uTrailAlpha);
}
+7
View File
@@ -17,6 +17,13 @@ export function getPaletteSize(): number {
return PALETTE_SIZE;
}
/**
* Max colors per transport-ship-trail gradient = rows in the trail-effect
* texture. Longer catalog color lists are truncated. Shared so the CPU side
* that fills the texture and the GPU side that allocates it can't drift.
*/
export const MAX_TRAIL_COLORS = 8;
// ---------- Terrain ----------
/** Parse a "#rrggbb" (or "rrggbb") hex string into an RGB tuple, or null. */
+38 -25
View File
@@ -102,27 +102,20 @@ export const SkinSchema = CosmeticSchema.extend({
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 })),
]);
// A boat trail is a gradient of one or more colors, cycled along the trail. The
// old solid/rainbow styles are just color lists now: solid = a single color,
// rainbow = the spectrum, gradient = two or more. The server only ships this
// "gradient" shape. Colors are unvalidated strings here; the renderer drops any
// it can't parse (and an empty list falls back to the player's territory color).
// `colorSize` is how wide each color band is, in tiles (larger = bigger bands);
// `movementSpeed` is how fast the bands scroll along the trail, in tiles per
// second (0 = static).
export const TransportShipTrailAttributesSchema = z.object({
type: z.literal("gradient"),
colors: z.array(z.string()),
colorSize: z.number(),
movementSpeed: z.number(),
});
const TransportShipTrailEffectSchema = CosmeticSchema.extend({
effectType: z.literal("transportShipTrail"),
@@ -135,6 +128,23 @@ export const EffectSchema = z.discriminatedUnion("effectType", [
TransportShipTrailEffectSchema,
]);
/**
* A record that drops entries failing `schema` instead of failing the whole
* parse. Used for the effect catalog: a newer effect the server ships before
* this client is updated to understand it is skipped rather than taking patterns,
* flags, and skins down with it.
*/
function lenientRecord<T extends z.ZodType>(schema: T) {
return z.record(z.string(), z.unknown()).transform((rec) => {
const out: Record<string, z.infer<T>> = {};
for (const [key, value] of Object.entries(rec)) {
const parsed = schema.safeParse(value);
if (parsed.success) out[key] = parsed.data;
}
return out;
});
}
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
@@ -157,12 +167,15 @@ export const CosmeticsSchema = z.object({
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.
// on it. Add a key per new effectType. Forward-compat: a brand-new effectType
// key is ignored (z.object strips keys it doesn't list), and lenientRecord
// extends that to new entries under a known effectType (a dropped effect just
// degrades to "no effect" — the trail keeps its territory color).
effects: z
.object({
transportShipTrail: z
.record(z.string(), TransportShipTrailEffectSchema)
.optional(),
transportShipTrail: lenientRecord(
TransportShipTrailEffectSchema,
).optional(),
})
.optional(),
currencyPacks: z.record(z.string(), PackSchema).optional(),
+113 -75
View File
@@ -15,94 +15,70 @@ describe("Effect cosmetic schemas", () => {
rarity: "common",
};
describe("TransportShipTrailAttributesSchema (lenient)", () => {
it("parses the known attribute variants", () => {
describe("TransportShipTrailAttributesSchema", () => {
it("parses a gradient with a color list, colorSize, and movementSpeed", () => {
const parsed = TransportShipTrailAttributesSchema.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(
TransportShipTrailAttributesSchema.safeParse({
type: "solid",
color: "#f00",
}).success,
).toBe(true);
expect(
TransportShipTrailAttributesSchema.safeParse({ type: "rainbow" })
.success,
).toBe(true);
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "pulse",
color: "#0f0",
type: "gradient",
colors: ["#f00"],
colorSize: 16,
movementSpeed: 0.15,
}).success,
).toBe(true);
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "gradient",
color: "#f00",
color2: "#00f",
colors: [],
colorSize: 16,
movementSpeed: 0.15,
}).success,
).toBe(true);
});
it("tolerates an unknown attribute type (ignored at render time)", () => {
it("requires the gradient type, colors, colorSize, and movementSpeed", () => {
// The old solid/rainbow/pulse styles are gone — only gradient remains.
expect(
TransportShipTrailAttributesSchema.safeParse({ type: "sparkle" })
.success,
).toBe(true);
});
it("requires a `type`", () => {
TransportShipTrailAttributesSchema.safeParse({ type: "solid" }).success,
).toBe(false);
// colors, colorSize, and movementSpeed are all required.
expect(
TransportShipTrailAttributesSchema.safeParse({
type: "gradient",
colors: ["#f00"],
}).success,
).toBe(false);
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" },
attributes: {
type: "gradient",
colors: ["#f00", "#0f0", "#00f"],
colorSize: 16,
movementSpeed: 0.15,
},
}).success,
).toBe(true);
});
@@ -116,18 +92,23 @@ describe("Effect cosmetic schemas", () => {
EffectSchema.safeParse({
...base,
effectType: "glow",
attributes: { type: "rainbow" },
attributes: {
type: "gradient",
colors: ["#f00"],
colorSize: 16,
movementSpeed: 0.15,
},
}).success,
).toBe(false);
});
it("tolerates an effect with an unknown attribute type", () => {
it("rejects an effect with a non-gradient attribute type", () => {
expect(
EffectSchema.safeParse({
...base,
attributes: { type: "sparkle" },
}).success,
).toBe(true);
).toBe(false);
});
});
@@ -143,7 +124,12 @@ describe("Effect cosmetic schemas", () => {
rainbow_ship: {
name: "rainbow_ship",
effectType: "transportShipTrail",
attributes: { type: "rainbow" },
attributes: {
type: "gradient",
colors: ["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"],
colorSize: 24,
movementSpeed: 0.2,
},
affiliateCode: null,
product: null,
priceHard: 123,
@@ -154,8 +140,9 @@ describe("Effect cosmetic schemas", () => {
effectType: "transportShipTrail",
attributes: {
type: "gradient",
color: "#aea2a2",
color2: "#a80000",
colors: ["#aea2a2", "#a80000"],
colorSize: 16,
movementSpeed: 0.15,
},
affiliateCode: null,
product: {
@@ -172,8 +159,9 @@ describe("Effect cosmetic schemas", () => {
expect(result.success).toBe(true);
if (result.success) {
expect(
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes?.type,
).toBe("rainbow");
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes
?.colors,
).toEqual(["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"]);
}
});
@@ -186,7 +174,12 @@ describe("Effect cosmetic schemas", () => {
ship: {
name: "ship",
effectType: "transportShipTrail",
attributes: { type: "solid", color: "#fff" },
attributes: {
type: "gradient",
colors: ["#fff"],
colorSize: 16,
movementSpeed: 0.15,
},
product: null,
rarity: "common",
},
@@ -203,12 +196,57 @@ describe("Effect cosmetic schemas", () => {
});
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: "solid", color: "#fff" } as const,
attributes: {
type: "gradient",
colors: ["#fff"],
colorSize: 16,
movementSpeed: 0.15,
} as const,
product: null,
rarity: "common" as const,
});
+18 -3
View File
@@ -99,7 +99,12 @@ const effectCosmetics = {
spectrum: {
name: "spectrum",
effectType: "transportShipTrail" as const,
attributes: { type: "rainbow" } as const,
attributes: {
type: "gradient" as const,
colors: ["#ff0000", "#00ff00", "#0000ff"],
colorSize: 16,
movementSpeed: 0.15,
},
url: "",
affiliateCode: null,
product: null,
@@ -110,7 +115,12 @@ const effectCosmetics = {
crimson: {
name: "crimson",
effectType: "transportShipTrail" as const,
attributes: { type: "solid", color: "#e01b24" } as const,
attributes: {
type: "gradient" as const,
colors: ["#e01b24"],
colorSize: 16,
movementSpeed: 0.15,
},
url: "",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
@@ -638,7 +648,12 @@ describe("Effect validation in isAllowed", () => {
trail_01: {
name: "spectrum",
effectType: "transportShipTrail" as const,
attributes: { type: "rainbow" } as const,
attributes: {
type: "gradient" as const,
colors: ["#ff0000", "#00ff00", "#0000ff"],
colorSize: 16,
movementSpeed: 0.15,
},
url: "",
affiliateCode: null,
product: null,