Zum Inhalt

src/screensaver/params.ts

// Query-param → settings bridge for the native OS-screensaver hosts.
//
// A real screensaver exits on any input, so the web control bar / hotkeys are
// unreachable there. The native hosts (Windows .scr config dialog, persisted to
// HKCU; macOS) instead pass the user's persistent choices in as URL query params.
// This pure seam maps those params onto the already-merged settings tree and is
// pinned by tests/screensaver-params.test.ts.
//
// Scene / colour preset / audio are applied at host-construction time in main.ts
// (they go through `overrides` / `sound.master`); everything here is a
// post-construction toggle on the nested settings objects.

import type { ScreensaverSettings } from '../engine/data/defaults';

const SPEEDS: readonly string[] = ['slow', 'norm', 'fast'];

/** 'on'/'1' → true, 'off'/'0' → false, absent or unrecognised → undefined (leave default). */
function readBool(params: URLSearchParams, key: string): boolean | undefined {
  const v = params.get(key);
  if (v === 'on' || v === '1') return true;
  if (v === 'off' || v === '0') return false;
  return undefined;
}

/** Raw numeric string (host passes values through unreformatted), validated +
 *  clamped to [lo, hi]; absent or malformed → undefined (leave default). */
function readNum(params: URLSearchParams, key: string, lo: number, hi: number): number | undefined {
  const raw = params.get(key);
  if (raw === null || !/^[0-9]+(\.[0-9]+)?$/.test(raw)) return undefined;
  const v = parseFloat(raw);
  return Math.min(hi, Math.max(lo, v));
}

/** Whitelist-validated enum; absent or unknown value → undefined (leave default). */
function readEnum<T extends string>(params: URLSearchParams, key: string, allowed: readonly T[]): T | undefined {
  const v = params.get(key);
  return v !== null && (allowed as readonly string[]).includes(v) ? (v as T) : undefined;
}

/** Mutates `settings` in place, applying any recognised query params. */
export function applyParamOverrides(settings: ScreensaverSettings, params: URLSearchParams): void {
  const speed = params.get('speed');
  if (speed && SPEEDS.includes(speed)) {
    settings.speed = speed as ScreensaverSettings['speed'];
  }

  const bloom = readBool(params, 'bloom');
  if (bloom !== undefined) settings.fx.bloom.on = bloom;

  const trails = readBool(params, 'trails');
  if (trails !== undefined) settings.fx.trails.on = trails;

  const scan = readBool(params, 'scan');
  if (scan !== undefined) settings.fx.scan.on = scan;

  const matrix = readBool(params, 'matrix');
  if (matrix !== undefined) settings.fx.matrix.on = matrix;

  const crt = readBool(params, 'crt');
  if (crt !== undefined) settings.crtSim.on = crt;

  // seed=<int>: pin the engine seed (settings.seedLock) so a render is reproducible
  // (scripts/render-sequence.mjs). Integers only; `readNum` would accept 1.5, and a
  // fractional seed would be lied about by the manifest that records it.
  const seedRaw = params.get('seed');
  if (seedRaw !== null && /^[0-9]{1,10}$/.test(seedRaw)) settings.seedLock = parseInt(seedRaw, 10);

  let hudOverridden = false;

  const terminal = readBool(params, 'terminal');
  if (terminal !== undefined) { settings.hud.terminal = terminal; hudOverridden = true; }

  const radar = readBool(params, 'radar');
  if (radar !== undefined) { settings.hud.radar = radar; hudOverridden = true; }

  const crosshair = readBool(params, 'crosshair');
  if (crosshair !== undefined) { settings.hud.crosshair = crosshair; hudOverridden = true; }

  // ── v0.10 keys (13–33 of the pinned host contract) ─────────────────────────
  // `look` is deliberately NOT read here: it is a dialog-only convenience key —
  // the settings dialog expands a Look into the individual CRT knobs before save,
  // so the query already carries the resolved values.

  const altitude = readEnum(params, 'altitude', ['low', 'mid', 'high'] as const);
  if (altitude !== undefined) settings.cityAltitude = altitude;

  const fog = readEnum(params, 'fog', ['auto', 'clear', 'dense'] as const);
  if (fog !== undefined) settings.fogMode = fog;

  const weather = readEnum(params, 'weather', ['light-fog', 'heavy-fog', 'storm', 'dust', 'clear'] as const);
  if (weather !== undefined) settings.weather = weather;

  const bootspeed = readEnum(params, 'bootspeed', ['fast', 'normal', 'cinematic'] as const);
  if (bootspeed !== undefined) settings.bootSpeed = bootspeed;

  const termlayout = readEnum(params, 'termlayout', ['strip', 'window'] as const);
  if (termlayout !== undefined) settings.terminalLayout = termlayout === 'window' ? 'center-window' : 'bottom-strip';

  const reactive = readBool(params, 'reactive');
  if (reactive !== undefined) settings.narrativeReactiveWorld = reactive;

  const autocycle = readBool(params, 'autocycle');
  if (autocycle !== undefined) settings.autoCycle.on = autocycle;

  const boot = readBool(params, 'boot');
  if (boot !== undefined) settings.bootEnabled = boot;

  const daynight = readBool(params, 'daynight');
  if (daynight !== undefined) settings.dayNightCycle.on = daynight;

  const perfadapt = readBool(params, 'perfadapt');
  if (perfadapt !== undefined) settings.perfAdapt = perfadapt;

  const bank = readNum(params, 'bank', 0, 2);
  if (bank !== undefined) settings.bankStrength = bank;

  const cyclemin = readNum(params, 'cyclemin', 0.5, 10);
  if (cyclemin !== undefined) settings.autoCycle.intervalMin = cyclemin;

  const crtintensity = readNum(params, 'crtintensity', 0, 1);
  if (crtintensity !== undefined) settings.crtSim.intensity = crtintensity;

  // Slider-driven FX pairs: the value doubles as the on-switch (≈0 → off).
  const curvature = readNum(params, 'curvature', 0, 0.25);
  if (curvature !== undefined) settings.fx.curvature = { on: curvature > 0.001, amount: curvature };

  const aperture = readNum(params, 'aperture', 0, 0.5);
  if (aperture !== undefined) settings.fx.aperture = { on: aperture > 0.001, strength: aperture };

  const trailsamount = readNum(params, 'trailsamount', 0.5, 0.95);
  if (trailsamount !== undefined) settings.fx.trails.damp = trailsamount;

  const ntsc = readNum(params, 'ntsc', 0, 1);
  if (ntsc !== undefined) settings.fx.ntsc = { on: ntsc > 0.001, amount: ntsc };

  const halation = readNum(params, 'halation', 0, 0.6);
  if (halation !== undefined) settings.fx.halation = { on: halation > 0.001, amount: halation };

  const scale = readNum(params, 'scale', 0.25, 1);
  if (scale !== undefined) settings.renderScale = scale;

  // Bloom strength: theme inheritance (effectiveSettings) would overwrite the
  // strength from the page theme — disable it so the explicit value sticks.
  const bloomstrength = readNum(params, 'bloomstrength', 0, 3);
  if (bloomstrength !== undefined) {
    settings.fx.bloom.strength = bloomstrength;
    settings.fxInheritFromTheme = false;
  }

  // Kiosk: UI-less operation in the native saver host — no control bar (the host
  // exits on input, the bar is unreachable and would burn in), cursor stays hidden.
  const kiosk = readBool(params, 'kiosk');
  if (kiosk) {
    settings.kioskMode = true;
    settings.hud.controlBar = false;
    hudOverridden = true;
  }

  // controller.open() re-applies the named HUD preset over hud.* unless the preset
  // is 'custom' (applyHudPreset, controller.ts:90-95). The default 'tactical' preset
  // would clobber the flags we just set, so switch to 'custom' to make them stick.
  // 'tactical' is identical to the default hud, so non-overridden fields are unchanged.
  if (hudOverridden) settings.hudPreset = 'custom';

  // hud=<preset>: a named HUD preset (minimal | tactical | full | off) — wins over the single
  // toggles above, because controller.open() re-applies the named preset over hud.*.
  // `off` is what a render for a composition wants: no readouts, no control bar, no kanji.
  const hudPreset = readEnum(params, 'hud', ['minimal', 'tactical', 'full', 'off'] as const);
  if (hudPreset !== undefined) settings.hudPreset = hudPreset;

  // ping=on|off: the radar ping ring is an fx, not part of a HUD preset — a render for a composition
  // must be able to switch it off on its own.
  const ping = readBool(params, 'ping');
  if (ping !== undefined) settings.fx.radarPing.on = ping;
}