// src/presets/storage.ts
import type { Preset } from '~/engine/types';
import { PresetSchema } from './schema';
const KEY = 'physarum:presets:custom';
const LAST_KEY = 'physarum:presets:lastSelected';
/** Lifts an old single-species preset into the multi-species schema. */
function migrateLegacyPreset(raw: unknown): unknown {
if (typeof raw !== 'object' || raw === null) return raw;
const p = raw as Record<string, unknown>;
const params = p.params as Record<string, unknown> | undefined;
if (!params || !('sensorAngle' in params)) return raw; // not a legacy preset
const main = {
color: params.trailColor,
populationShare: 1,
sensorAngle: params.sensorAngle,
sensorDistance: params.sensorDistance,
turnSpeed: params.turnSpeed,
moveSpeed: params.moveSpeed,
depositAmount: params.depositAmount,
};
const empty = { ...main, color: [0, 0, 0], populationShare: 0 };
return {
...p,
params: {
agentCount: params.agentCount,
spawnPattern: 'random',
species: [main, empty, empty],
interactionMatrix: [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
],
decayRate: 0.97,
diffusionFreq: 3,
fadeStrength: 0.1,
trailSaturation: 255,
attractionStrength: 1.0,
repulsionStrength: -0.2,
chaosInterval: 0,
chaosStrength: 0,
rhythmAmplitude: 0.05,
backgroundColor: params.backgroundColor,
exposure: params.exposure,
feedbackAmount: 0.2,
bloomThreshold: 0.25,
bloomIntensity: 0.9,
vignette: 0.3,
saturation: 1.1,
contrast: 1.05,
audioReactive: params.audioReactive ?? {
enabled: false,
gain: 1,
autoGain: false,
routes: [],
},
},
};
}
export function loadCustomPresets(): Preset[] {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const valid: Preset[] = [];
for (const item of parsed) {
let result = PresetSchema.safeParse(item);
if (!result.success) result = PresetSchema.safeParse(migrateLegacyPreset(item));
if (result.success) valid.push(result.data as Preset);
}
return valid;
} catch {
return [];
}
}
export function saveCustomPreset(preset: Preset): void {
// Update in place when the preset already exists so re-saving an
// existing preset doesn't bounce it to the end of the list — users
// expect a "Save" on a tweaked preset to leave the library order alone.
const all = loadCustomPresets();
const idx = all.findIndex((p) => p.id === preset.id);
const entry = { ...preset, builtIn: false };
if (idx === -1) all.push(entry);
else all[idx] = entry;
localStorage.setItem(KEY, JSON.stringify(all));
}
export function deleteCustomPreset(id: string): void {
const filtered = loadCustomPresets().filter((p) => p.id !== id);
localStorage.setItem(KEY, JSON.stringify(filtered));
}
/** Atomically replaces the custom-preset list. Used by reorder operations. */
export function saveAllCustomPresets(presets: readonly Preset[]): void {
const normalised = presets.map((p) => ({ ...p, builtIn: false }));
localStorage.setItem(KEY, JSON.stringify(normalised));
}
/** Stores the id of the last selected preset so the next session can
* restore it. Silent on failure — localStorage may be unavailable in
* tests or private-browsing mode. */
export function saveLastSelectedId(id: string): void {
try {
localStorage.setItem(LAST_KEY, id);
} catch {
/* ignore */
}
}
export function loadLastSelectedId(): string | null {
try {
return localStorage.getItem(LAST_KEY);
} catch {
return null;
}
}
export function newCustomId(name: string): string {
const slug = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const stamp = Date.now().toString(36);
return `custom-${slug || 'preset'}-${stamp}`;
}