Zum Inhalt

src/presets/schema.ts

// 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 };
}