// src/presets/schema.ts
import { z } from 'zod';
import {
AUDIO_SOURCES,
BLEND_MODES,
MOD_TARGETS,
type Preset,
SIGNAL_NAMES,
SPAWN_PATTERNS,
} from '~/engine/types';
const RGB = z.tuple([z.number().min(0).max(1), z.number().min(0).max(1), z.number().min(0).max(1)]);
const SignalSchema = z.enum(SIGNAL_NAMES);
const MatrixValue = z.number().min(-2).max(2);
const MatrixRow = z.tuple([MatrixValue, MatrixValue, MatrixValue]);
const AudioRouteSchema = z.object({
source: z.enum(AUDIO_SOURCES),
target: z.enum(MOD_TARGETS),
depth: z.number().min(-2).max(2),
});
// Accepts both the new `routes`-based shape and the legacy `{bassMod,midMod,highMod}`
// shape from v1.0 presets — pre-1.1 presets are silently lifted into the new format.
const AudioReactiveSchema = z
.union([
z.object({
enabled: z.boolean(),
gain: z.number().min(0.1).max(10),
autoGain: z.boolean(),
routes: z.array(AudioRouteSchema).max(16),
}),
z.object({
enabled: z.boolean(),
bassMod: z.number().min(0).max(2),
midMod: z.number().min(0).max(2),
highMod: z.number().min(0).max(2),
}),
])
.transform((v) => {
if ('routes' in v) return v;
// Lift legacy v1.0 hardcoded mods into the routes list.
return {
enabled: v.enabled,
gain: 1,
autoGain: false,
routes: [
{ source: 'bass' as const, target: 'moveSpeed' as const, depth: v.bassMod },
{ source: 'mid' as const, target: 'turnSpeed' as const, depth: v.midMod },
{ source: 'high' as const, target: 'depositAmount' as const, depth: v.highMod },
],
};
});
const SpeciesParamsSchema = z.object({
color: RGB,
populationShare: z.number().min(0),
sensorAngle: z.number().min(0).max(Math.PI),
sensorDistance: z.number().min(1).max(80),
turnSpeed: z.number().min(0).max(2),
moveSpeed: z.number().min(0).max(10),
depositAmount: z.number().min(0).max(100),
});
// Pre-1.2 presets carry global attractionStrength + repulsionStrength
// scalars on top of the interactionMatrix. The 1.2+ shape folds them into
// the matrix and drops the two fields. This preprocess does the lift, so
// the schema itself stays in the new shape.
function foldLegacyStrengths(raw: unknown): unknown {
if (typeof raw !== 'object' || raw === null) return raw;
const r = raw as Record<string, unknown>;
const hasAttr = 'attractionStrength' in r;
const hasRepul = 'repulsionStrength' in r;
if (!hasAttr && !hasRepul) return raw;
const attrK = typeof r.attractionStrength === 'number' ? r.attractionStrength : 1.0;
const repulK = typeof r.repulsionStrength === 'number' ? r.repulsionStrength : -0.2;
const matrix = r.interactionMatrix;
if (!Array.isArray(matrix)) return raw;
const folded = matrix.map((row, ri) =>
Array.isArray(row) ? row.map((c, ci) => Number(c) * (ri === ci ? attrK : repulK)) : row,
);
const { attractionStrength: _a, repulsionStrength: _re, ...rest } = r;
return { ...rest, interactionMatrix: folded };
}
export const PresetParamsSchema = z.preprocess(
foldLegacyStrengths,
z
.object({
agentCount: z.number().int().min(100).max(500_000),
spawnPattern: z.enum(SPAWN_PATTERNS),
species: z.tuple([SpeciesParamsSchema, SpeciesParamsSchema, SpeciesParamsSchema]),
interactionMatrix: z.tuple([MatrixRow, MatrixRow, MatrixRow]),
decayRate: z.number().min(0.5).max(1),
diffusionFreq: z.number().int().min(1).max(20),
fadeStrength: z.number().min(0).max(1),
trailSaturation: z.number().min(1).max(500),
chaosInterval: z.number().int().min(0).max(2000),
chaosStrength: z.number().min(0).max(2),
rhythmAmplitude: z.number().min(0).max(0.5),
// Pre-1.2 presets don't have simSpeed; default to 1 keeps their behaviour.
simSpeed: z.number().int().min(1).max(8).default(1),
backgroundColor: RGB,
// Pre-1.3 presets don't have blendMode; default to additive (the
// historical behaviour).
blendMode: z.enum(BLEND_MODES).default('additive'),
// Pre-1.3 presets don't have headIntensity; default 0 = off keeps
// their long-exposure behaviour identical to before.
headIntensity: z.number().min(0).max(3).default(0),
// Pre-1.3 presets don't have kaleidoscope; default 0 = off.
kaleidoscope: z.number().int().min(0).max(6).default(0),
exposure: z.number().min(0.1).max(10),
feedbackAmount: z.number().min(0).max(0.97),
bloomThreshold: z.number().min(0).max(2),
bloomIntensity: z.number().min(0).max(3),
vignette: z.number().min(0).max(1),
saturation: z.number().min(0).max(2),
contrast: z.number().min(0).max(2),
audioReactive: AudioReactiveSchema,
})
.refine(
(p) =>
p.species[0].populationShare + p.species[1].populationShare + p.species[2].populationShare >
0,
{ message: 'at least one species must have populationShare > 0' },
),
);
export const PresetSchema = z.object({
id: z.string().min(1).max(64),
name: z.string().min(1).max(64),
author: z.string().optional(),
builtIn: z.boolean(),
tags: z.array(z.string()).max(8),
tagSignal: SignalSchema,
params: PresetParamsSchema,
});
export type ValidationResult = { kind: 'ok'; preset: Preset } | { kind: 'error'; message: string };
export function validatePreset(input: unknown): ValidationResult {
const result = PresetSchema.safeParse(input);
if (result.success) return { kind: 'ok', preset: result.data as Preset };
return { kind: 'error', message: result.error.message };
}