engine/core/dpl/renderer.js

/**
 * @file
 * @brief DPL renderer: run the flow over a parsed node tree, applying gates / choices /
 * repeats / dial conditions / references and weight-sorting each layer. `renderNodes` is the
 * entry point the compiler wires into each section's render. See notes/reference/dpl-design.md.
 */

import { condPasses, scaleCount, applyIntensityMod, clampIntensity } from "./intensity.js";
import { intensityWord, focusWord } from "./words.js";

const AUTO_WEIGHT_START = 1000; // first auto-assigned line weight; +1 per following line

// ---------------------------------------------------------------------------
// Rendering: run the flow, collect weighted pieces, sort within the layer
// ---------------------------------------------------------------------------

/**
 * Weighted-sort a list of `{ weight, text }` pieces (stable; ties keep document order) and
 * join the non-empty ones with ", ".
 * @param {Array<{weight: number, text: string}>} pieces The collected layer pieces.
 * @returns {string} The joined layer text.
 */
function joinPieces(pieces) {
  return pieces
    .map((p, i) => ({ ...p, i }))
    .sort((a, b) => a.weight - b.weight || a.i - b.i)
    .map((p) => p.text)
    .filter((s) => s && s.trim() !== "")
    .join(", ");
}

/**
 * Render an array of sibling nodes into one layer string (gates, choices, repeats, refs,
 * weighted local sort). `auto` tracks the running auto-weight (starts at 1000).
 * @param {Array} nodes Parsed sibling nodes.
 * @param {object} ctx The render context (settings, rng, bridge, sections).
 * @returns {string} The layer's rendered, weight-sorted text.
 */
/**
 * Hard dial conditions (`[i<10%]`, `[f<40%]`): deterministic include/exclude, evaluated BEFORE any
 * probability roll. Both an intensity and a focus condition may apply; both must pass to keep the line.
 * @returns {boolean} True when the node is excluded by a failing dial condition.
 */
function dialExcluded(node, ctx) {
  return (
    (node.iCond && !condPasses(node.iCond, ctx.intensity)) ||
    (node.fCond && !condPasses(node.fCond, ctx.focus))
  );
}

/**
 * The node's effective gate. An explicit gate (NN%/maybe/NN% chance/otherwise) always wins; a bare
 * *simple-clause* bullet (plain text / token / ref) defaults to 50%. Structural bullets (one of /
 * repeat / block) and plain (non-bullet) lines are unconditional. A probability gate is auto-scaled
 * by the current intensity (`scaleGate`); the bare-`otherwise` gate of 1 is not.
 * @returns {{gate: number|null|undefined, scaleGate: boolean}}
 */
function effectiveGate(node) {
  let gate = node.gate;
  let scaleGate = node.scaleGate === true;
  if (gate == null && !node.otherwise) {
    const structural = node.choice || node.repeat || node.flow || node.block;
    if (node.bullet && !structural) {
      gate = 0.5;
      scaleGate = true;
    }
  }
  return { gate, scaleGate };
}

/**
 * Decide whether a node runs, given the running `prevGateFailed`. Rolls a probability gate through
 * the seeded rng. `gateBearing` (an authored gate, not the default) is what an `otherwise` pairs
 * against — a non-bearing node never leaves a "failed gate" behind.
 * @returns {{run: boolean, gateFailed: boolean}}
 */
function gateDecision(node, ctx, prevGateFailed) {
  const gateBearing = node.gate != null || node.otherwise === true;
  const { gate, scaleGate } = effectiveGate(node);
  let run = node.otherwise ? prevGateFailed : true;
  if (run && gate != null) {
    run = ctx.rng.chance(scaleGate ? gate * (ctx.intensity / 100) : gate);
  }
  return { run, gateFailed: gateBearing ? !run : false };
}

export function renderNodes(nodes, ctx) {
  const pieces = [];
  let auto = AUTO_WEIGHT_START;
  let prevGateFailed = false;
  for (const node of nodes) {
    const weight = node.weight ?? auto;
    auto = weight + 1;

    if (dialExcluded(node, ctx)) {
      prevGateFailed = false;
      continue;
    }

    const { run, gateFailed } = gateDecision(node, ctx, prevGateFailed);
    prevGateFailed = gateFailed;
    if (!run) continue;

    const text = renderNode(node, ctx);
    if (text && text.trim() !== "") pieces.push({ weight, text: text.trim() });
  }
  return joinPieces(pieces);
}

/**
 * Render a single parsed node to a string (without its sibling gate, which the caller applied).
 * @param {object} node A parsed node.
 * @param {object} ctx The render context.
 * @returns {string} The node's text contribution.
 */
/**
 * Choice: pick 1..N options (weighted by each option's leading %), honoring a miss chance. The pick
 * count is scaled by intensity, so low intensity yields fewer (possibly zero) picks.
 */
function renderChoice(node, ctx) {
  if (node.choice.miss && ctx.rng.chance(node.choice.miss)) return "";
  const opts = node.children.slice();
  if (!opts.length) return "";
  const hi = Math.min(scaleCount(node.choice.max, ctx.intensity), opts.length);
  const lo = Math.min(scaleCount(node.choice.min, ctx.intensity), hi);
  const count = ctx.rng.int(lo, hi);
  if (count <= 0) return "";
  const picked = weightedSampleN(opts, count, ctx.rng);
  return picked
    .map((o) => renderNode(o, ctx))
    .filter(Boolean)
    .join(", ");
}

/**
 * Repeat: loop count times, rendering the body (payload or child block) each time. The count is
 * scaled by intensity (round(n × intensity/100)), so the dial thins/thickens repetition.
 */
function renderRepeat(node, ctx) {
  const lo = scaleCount(node.repeat.min, ctx.intensity);
  const hi = scaleCount(node.repeat.max, ctx.intensity);
  const count = ctx.rng.int(Math.min(lo, hi), Math.max(lo, hi));
  const parts = [];
  for (let i = 0; i < count; i++) {
    const part = node.children.length
      ? renderNodes(node.children, ctx)
      : renderInlineBody(node, ctx);
    if (part && part.trim() !== "") parts.push(part.trim());
  }
  return parts.join(", ");
}

function renderNode(node, ctx) {
  if (node.choice) return renderChoice(node, ctx);
  if (node.repeat) return renderRepeat(node, ctx);

  // References.
  if (node.ref) return renderRef(node.ref, weightOf(node), ctx);

  // Plain gated block (`maybe:` etc. with children, no choice/repeat).
  if (node.block || (node.children.length && !node.payload)) {
    return renderNodes(node.children, ctx);
  }

  // Flow (MVP: goto/insert behave like a call+include; back is a no-op terminator).
  if (node.flow) {
    if (node.flow.kind === "goto") return ctx.section(node.flow.target);
    return "";
  }

  return renderInlineBody(node, ctx);
}

/** Render the payload text of a node, substituting inline `{js:path}` via the bridge. */
function renderInlineBody(node, ctx) {
  let t = node.payload || "";
  // Dial keyword tokens (resolved here, where the dials are known), each with an optional relative
  // modifier. The dial IS a percent, so `$intensity` / `$focus` expands to the percent itself (`50%`)
  // — there is no separate `%` form. `$intensity-word` / `$focus-word` is the natural-language word
  // (`normal`, `pure`). A trailing ` ±NN%` derives a value off the dial (`$intensity-word +25%`,
  // `$focus -10%`). The `$` sigil keeps them distinct from `{list}` syntax.
  // See notes/reference/intensity-design.md and notes/reference/focus-design.md.
  t = t.replace(
    /\$(intensity|focus)(-word)?(?:\s*([+-]\d+(?:\.\d+)?)%)?/g,
    (_m, dial, fmt, mod) => {
      const isFocus = dial === "focus";
      const v = applyIntensityMod(isFocus ? ctx.focus : ctx.intensity, mod);
      if (fmt === "-word") return isFocus ? focusWord(v) : intensityWord(v);
      return `${v}%`; // the dial is inherently a percent
    },
  );
  // Nested refs carrying dial args — `{#name i25% f80%}`, with MANDATORY `i`/`f` prefixes. Each arg may
  // be absolute (`i80%`) or relative (`i+25%`, `f-40%`); relatives derive an ABSOLUTE percent from the
  // current dial. Normalized here to absolute, prefixed args so the flat downstream resolver only ever
  // sees `{#name iNN% fNN%}`. (An unprefixed `{#name 25%}` is not dial syntax and is left untouched.)
  t = t.replace(/\{#([\w/-]+)((?:\s+[if][+-]?\d+(?:\.\d+)?%)+)\}/gi, (_m, name, args) => {
    let iVal = null;
    let fVal = null;
    const tokRe = /([if])([+-]?)(\d+(?:\.\d+)?)%/gi;
    let tm;
    while ((tm = tokRe.exec(args))) {
      const dial = tm[1].toLowerCase();
      const base = dial === "f" ? ctx.focus : ctx.intensity;
      const val = tm[2] ? applyIntensityMod(base, tm[2] + tm[3]) : clampIntensity(Number(tm[3]));
      if (dial === "f") fVal = val;
      else iVal = val;
    }
    let out = `{#${name}`;
    if (iVal != null) out += ` i${iVal}%`;
    if (fVal != null) out += ` f${fVal}%`;
    return out + "}";
  });
  // Inline JS values: {js:path}
  t = t.replace(/\{js:([^}]+)\}/g, (_m, p) => ctx.bridge?.resolveJs?.(p.trim(), ctx) ?? "");
  // A child block alongside a payload line (rare) — append.
  if (node.children.length && !node.choice && !node.repeat) {
    const sub = renderNodes(node.children, ctx);
    if (sub) t = t ? `${t}, ${sub}` : sub;
  }
  return t;
}

/** Resolve a reference node (call/insert/js-block) to a string. */
function renderRef(ref, _weight, ctx) {
  if (ref.kind === "call" || ref.kind === "insert") {
    // A DPL-side section (local) or another generator/list/expansion token (passthrough).
    if (ctx.hasSection(ref.name)) return ctx.section(ref.name);
    if (ref.name.startsWith("#")) return `{${ref.name}}`; // +#weather -> {#weather}
    return `{#${ref.name}}`; // bare name -> block token, resolved downstream
  }
  if (ref.kind === "js-block") return ctx.bridge?.resolveJs?.(ref.path, ctx) ?? "";
  return "";
}

/** A node's explicit weight, or null (used for refs that carry a weight). */
function weightOf(node) {
  return node.weight ?? null;
}

/**
 * Pick `n` distinct options weighted by each option's leading gate %, else uniform. Draws from the
 * render context's rng (`ctx.rng`) so the pick is part of the seeded stream — not `Math.random`.
 * @param {Array} opts The option nodes.
 * @param {number} n How many to pick.
 * @param {{float: Function}} rng The seam's random source.
 * @returns {Array} The picked option nodes.
 */
function weightedSampleN(opts, n, rng) {
  const pool = opts.map((o) => ({ o, w: o.gate ?? 1 }));
  const picked = [];
  for (let k = 0; k < n && pool.length; k++) {
    const total = pool.reduce((s, e) => s + e.w, 0);
    let r = rng.float() * total;
    let idx = 0;
    for (; idx < pool.length; idx++) {
      r -= pool[idx].w;
      if (r <= 0) break;
    }
    picked.push(pool[Math.min(idx, pool.length - 1)].o);
    pool.splice(Math.min(idx, pool.length - 1), 1);
  }
  return picked;
}