Zum Inhalt

src/stores/presetStore.ts

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