// src/stores/presetStore.ts
import { createStore, produce, unwrap } from 'solid-js/store';
import type {
InteractionMatrix,
MatrixRow,
Preset,
PresetParams,
SignalName,
SpeciesParams,
} from '~/engine/types';
import { BUILT_IN_PRESETS, DEFAULT_PRESET } from '~/presets/builtIn';
import {
deleteCustomPreset,
loadCustomPresets,
loadLastSelectedId,
newCustomId,
saveAllCustomPresets,
saveCustomPreset,
saveLastSelectedId,
} from '~/presets/storage';
import { triggerRespawn } from './uiStore';
// All paths that replace `edited` go through this so the Solid store proxy
// is unwrapped before structuredClone. Skipping unwrap throws DataCloneError
// in the browser, which silently breaks save / reset / preset-switch flows.
function cloneParams(p: PresetParams): PresetParams {
return structuredClone(unwrap(p)) as PresetParams;
}
interface PresetState {
current: Preset;
edited: PresetParams; // live-editable copy of current.params
isDirty: boolean; // edited differs from current.params
customPresets: Preset[];
}
function makeInitial(): PresetState {
const customs = loadCustomPresets();
// Restore the last-selected preset across page reloads. Looks in
// builtins first, then customs. Falls back to DEFAULT_PRESET if the
// saved id no longer exists (preset deleted, etc.).
const lastId = loadLastSelectedId();
const restored = lastId
? (BUILT_IN_PRESETS.find((p) => p.id === lastId) ?? customs.find((p) => p.id === lastId))
: undefined;
const current = restored ?? DEFAULT_PRESET;
return {
current,
edited: cloneParams(current.params),
isDirty: false,
customPresets: customs,
};
}
const [presetState, setPresetState] = createStore<PresetState>(makeInitial());
export { presetState };
export function selectPreset(preset: Preset): void {
// Note: changes to params.agentCount take effect on next engine init.
// The engine's agent buffer is not reallocated mid-session — callers using
// this store to drive a running engine must accept the existing agent count.
setPresetState(
produce((s) => {
s.current = preset;
s.edited = cloneParams(preset.params);
s.isDirty = false;
}),
);
saveLastSelectedId(preset.id);
}
export function updateEdited<K extends keyof PresetParams>(key: K, value: PresetParams[K]): void {
setPresetState(
produce((s) => {
(s.edited[key] as PresetParams[K]) = value;
s.isDirty = true;
}),
);
}
export function updateSpecies<K extends keyof SpeciesParams>(
index: number,
key: K,
value: SpeciesParams[K],
): void {
const next = presetState.edited.species.map((s, i) =>
i === index ? ({ ...s, [key]: value } as SpeciesParams) : s,
) as unknown as readonly [SpeciesParams, SpeciesParams, SpeciesParams];
updateEdited('species', next);
}
export function updateAllSpecies<K extends keyof SpeciesParams>(
key: K,
value: SpeciesParams[K],
): void {
const next = presetState.edited.species.map(
(s) => ({ ...s, [key]: value }) as SpeciesParams,
) as unknown as readonly [SpeciesParams, SpeciesParams, SpeciesParams];
updateEdited('species', next);
}
export function updateMatrixCell(row: number, col: number, value: number): void {
const next = presetState.edited.interactionMatrix.map((r, ri) =>
ri === row ? (r.map((c, ci) => (ci === col ? value : c)) as unknown as MatrixRow) : r,
) as unknown as InteractionMatrix;
updateEdited('interactionMatrix', next);
}
export function resetEdited(): void {
setPresetState(
produce((s) => {
s.edited = cloneParams(s.current.params);
s.isDirty = false;
}),
);
// Match the preset-switch semantics: a full reset re-seeds agents so
// spawn-pattern / population-share / agent-count revert visually too,
// not just numerically in the editor.
triggerRespawn();
}
// Patches the current preset's metadata (name, tags, tagSignal) and
// persists the change to localStorage. No-op on built-ins.
export function updateCurrentMetadata(patch: {
name?: string;
tags?: readonly string[];
tagSignal?: SignalName;
}): Preset | null {
if (presetState.current.builtIn) return null;
const updated: Preset = {
...presetState.current,
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.tags !== undefined ? { tags: patch.tags } : {}),
...(patch.tagSignal !== undefined ? { tagSignal: patch.tagSignal } : {}),
};
saveCustomPreset(updated);
setPresetState(
produce((s) => {
s.customPresets = loadCustomPresets();
s.current = updated;
}),
);
return updated;
}
// Overwrites the current preset's params with the live edited values.
// No-op for built-in presets — those are immutable, the editor offers
// "Save as new" instead.
export function saveOverwrite(): Preset | null {
if (presetState.current.builtIn) return null;
const updated: Preset = {
...presetState.current,
params: cloneParams(presetState.edited),
};
saveCustomPreset(updated);
setPresetState(
produce((s) => {
s.customPresets = loadCustomPresets();
s.current = updated;
s.isDirty = false;
}),
);
return updated;
}
export function saveAsNew(name: string): Preset {
const id = newCustomId(name);
// unwrap() strips the Solid store proxy so structuredClone doesn't choke
// on it. Without unwrap, structuredClone throws DataCloneError on the
// reactive proxy and the save silently fails.
const created: Preset = {
id,
name,
builtIn: false,
tags: presetState.current.tags,
tagSignal: presetState.current.tagSignal,
params: cloneParams(presetState.edited),
};
saveCustomPreset(created);
setPresetState(
produce((s) => {
s.customPresets = loadCustomPresets();
s.current = created;
s.isDirty = false;
}),
);
saveLastSelectedId(created.id);
return created;
}
/**
* Moves a custom preset up or down by one slot in the library. No-op if
* the preset doesn't exist or is already at the boundary. Persists the
* new order to localStorage so it survives a reload.
*/
export function moveCustom(id: string, direction: 'up' | 'down'): void {
const list = [...presetState.customPresets];
const idx = list.findIndex((p) => p.id === id);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= list.length) return;
const [moved] = list.splice(idx, 1) as [(typeof list)[number]];
list.splice(target, 0, moved);
saveAllCustomPresets(list);
setPresetState(
produce((s) => {
s.customPresets = list;
}),
);
}
export function deleteCustom(id: string): void {
deleteCustomPreset(id);
setPresetState(
produce((s) => {
s.customPresets = loadCustomPresets();
if (s.current.id === id) {
s.current = DEFAULT_PRESET;
s.edited = cloneParams(DEFAULT_PRESET.params);
s.isDirty = false;
}
}),
);
}
export function allPresets(): readonly Preset[] {
return [...BUILT_IN_PRESETS, ...presetState.customPresets];
}