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