From 7c151e76ad0f99e2c5c030eadf6493f2a03bea8e Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 29 Jun 2026 20:28:47 -0700 Subject: [PATCH] feat: render transport-ship trail cosmetic as a gradient (#4454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/client/WebGLFrameBuilder.ts | 90 ++++++++- src/client/components/EffectPreview.ts | 53 ++--- src/client/render/gl/MapRenderer.ts | 3 + src/client/render/gl/Renderer.ts | 40 +++- src/client/render/gl/index.ts | 6 +- src/client/render/gl/passes/TrailPass.ts | 12 ++ .../gl/shaders/map-overlay/trail.frag.glsl | 36 +++- src/client/render/gl/utils/ColorUtils.ts | 7 + src/core/CosmeticSchemas.ts | 63 +++--- tests/CosmeticSchemas.test.ts | 188 +++++++++++------- tests/Privilege.test.ts | 21 +- 11 files changed, 373 insertions(+), 146 deletions(-) diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 0e6422a96..84ae010a0 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -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(); + /** + * 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(); /** * 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 0–2 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, diff --git a/src/client/components/EffectPreview.ts b/src/client/components/EffectPreview.ts index 21b78eb33..afee3dcf6 100644 --- a/src/client/components/EffectPreview.ts +++ b/src/client/components/EffectPreview.ts @@ -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`
`; - case "gradient": - return html`
`; - case "pulse": - return html`
`; - case "solid": - return html`
`; - default: - // Unknown / unrecognized style — neutral swatch. - return html`
`; - } + const colors = attributes.colors; + const background = + colors.length === 0 + ? EMPTY_BG + : colors.length === 1 + ? colors[0] + : `linear-gradient(90deg,${colors.join(",")})`; + return html`
`; } diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 08ba9d862..af7fb92a3 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -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, diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 51db52a32..c8e2c240c 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -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); diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index f5de04d10..cffd5e134 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -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"; diff --git a/src/client/render/gl/passes/TrailPass.ts b/src/client/render/gl/passes/TrailPass.ts index 6bb658ec1..c0cb7a383 100644 --- a/src/client/render/gl/passes/TrailPass.ts +++ b/src/client/render/gl/passes/TrailPass.ts @@ -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, 1–255=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); diff --git a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl index 45d832819..8ab748451 100644 --- a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl @@ -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); } diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index bfe9b46e6..8da6ac2df 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -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. */ diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 8b84eacff..84c66779a 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -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(schema: T) { + return z.record(z.string(), z.unknown()).transform((rec) => { + const out: Record> = {}; + 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(), diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index 068a0904f..3caa7481c 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -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, }); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 8626993eb..eef54c753 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -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,