Zum Inhalt

src/ui/respawnCoordinator.ts

// Single coordinator for "the engine should re-seed agents now."
//
// Three paths can request a respawn:
//   1. uiState.respawnNonce  — explicit user action (hotkey, reset button)
//   2. presetState.current.id change — preset switch
//   3. seed-time param edits (agentCount, spawnPattern, populationShare)
//
// Paths 1 and 2 fire immediately. Path 3 is debounced so dragging a slider
// doesn't respawn on every intermediate value — only after the user pauses.
//
// To avoid double-respawn on preset-switch (where both path 2 AND path 3 see
// changes in the same batch), path 3 is guarded by `presetState.isDirty`:
// selectPreset and resetEdited both set isDirty=false in the same produce
// that mutates the seed params, so path 3 early-returns and only the
// immediate respawn from path 2 (or path 1 for reset) takes effect.
import { createEffect, createMemo, on, onCleanup } from 'solid-js';
import { presetState } from '~/stores/presetStore';
import { uiState } from '~/stores/uiStore';

interface CoordinatorOpts {
  /** Called once per coalesced respawn request. */
  performRespawn: () => void;
  /** Optional hook for preset-switch-only side effects (e.g. resetting
   *  auto-snapshot bookkeeping). Runs before performRespawn. */
  onPresetSwitch?: () => void;
  /** Debounce window for seed-param edits. Default 250 ms. */
  debounceMs?: number;
}

export function createRespawnCoordinator(opts: CoordinatorOpts): void {
  const debounce = opts.debounceMs ?? 250;
  let timer: ReturnType<typeof setTimeout> | null = null;

  const cancelTimer = (): void => {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
  };

  // Path 1: explicit respawn nonce — immediate, cancels any pending debounce.
  createEffect(
    on(
      () => uiState.respawnNonce,
      () => {
        cancelTimer();
        opts.performRespawn();
      },
      { defer: true },
    ),
  );

  // Path 2: preset switch — immediate, cancels any pending debounce.
  createEffect(
    on(
      () => presetState.current.id,
      () => {
        cancelTimer();
        opts.onPresetSwitch?.();
        opts.performRespawn();
      },
      { defer: true },
    ),
  );

  // Path 3: seed-time param edits — debounced. Each primitive dep is wrapped
  // in createMemo so the value is dedup'd on `===`: editing moveSpeed (or any
  // non-seed species property) replaces the whole species array, which fires
  // the species signal — but populationShare is unchanged, so the memo
  // doesn't re-emit, and this effect doesn't re-run. Without memos, `on()`
  // would fire on every species-array replacement regardless of value.
  const m_agentCount = createMemo(() => presetState.edited.agentCount);
  const m_spawnPattern = createMemo(() => presetState.edited.spawnPattern);
  const m_share0 = createMemo(() => presetState.edited.species[0]?.populationShare ?? 0);
  const m_share1 = createMemo(() => presetState.edited.species[1]?.populationShare ?? 0);
  const m_share2 = createMemo(() => presetState.edited.species[2]?.populationShare ?? 0);

  createEffect(
    on(
      [m_agentCount, m_spawnPattern, m_share0, m_share1, m_share2],
      () => {
        // Skip when no user-edit is in flight. selectPreset and resetEdited
        // both set isDirty=false in the same batch that mutates seed params,
        // and they route through paths 1/2 for the actual respawn.
        if (!presetState.isDirty) return;
        cancelTimer();
        timer = setTimeout(() => {
          timer = null;
          opts.performRespawn();
        }, debounce);
      },
      { defer: true },
    ),
  );

  onCleanup(cancelTimer);
}