engine/effects/resolve-params
PURPOSE
Tiny $var substitution layer for the unified effect pipeline. Replaces strings of the form "$key" in action and condition param dicts with numeric values pulled from an EffectInstance’s values dict (artifact tier values, passive level values, runtime-bound stat values), and provides typed wrappers that coerce the resolved result to number or string for call sites that need a hard cast.
OWNS
- The four exported pure functions:
resolveParam,resolveAllParams,resolveNumber,resolveString. - The
$-prefix recognition rule itself — a string whose first code unit is36('$') is treated as a value-dict lookup; every other input passes through. - The “missing key throws” contract — when a
$keylookup hitsundefinedin the values dict, the resolver raisesError("Effect param $<key> not found in values dict")rather than returning a default. - The “expected number” contract on
resolveNumber— when the resolved value is not atypeof === 'number', it raisesError("Expected number for param, got <type>: <value>"). - The
String(v)coercion used byresolveStringfor non-string resolved values.
READS FROM
- A caller-supplied
values: Record<string, number>dict — for everyEffectInstancein the runtime that is the instance’s ownvaluesfield, populated when the def is registered with tier / level values bound in. - The raw param value itself —
number,string, orboolean— passed through fromaction.params[k]orcond.params[k](or any literal default the caller supplied via??).
Has no imports. Touches no module-level state and no engine module.
PUSHES TO
- Nothing. The functions are pure: they return a fresh value (or a fresh object, for
resolveAllParams) and never mutate the inputparamsorvaluesdicts. - Errors thrown propagate to the caller — typically
executeAllActionsorevaluateAllConditions— which surfaces them up throughEffectEngine._tryExecuteand out to the per-frame tick.
DOES NOT
- Does not own the
valuesdict — that lives onEffectInstanceand is populated by whichever loader registers the effect. - Does not own param schema or validation — it does not know which actions or conditions accept which keys; callers pass
action.params[k] ?? <default>and ask for whichever type they need. - Does not memoize or cache — every call re-walks the input dict.
- Does not handle nested templates, arithmetic, or multi-
$varinterpolation. The entire$varsyntax is “string starts with$, the rest is the key” —"$a + $b"is not a substitution, it is passed through as a literal string. - Does not handle escaping a literal leading
$. Any string whose first character is$is treated as a lookup. - Does not return
undefinedon a missing key — it throws. There is no silent fallback. - Does not coerce booleans to numbers itself.
resolveNumberwill throw on abooleaninput that was passed through unchanged. Callers that need the boolean-to-0/1coercion do it before calling (typeof v === 'boolean' ? (v ? 1 : 0) : v) —effect-engine.tsandactions.tsboth follow that pattern at theircustom_tickand registry-action dispatch sites. - Does not call
console.logor emit telemetry.
Signals
Subscribes to no signals. Emits no signals. Has no awareness of Sig.
Entry points
resolveParam(raw, values)— single-value resolver. Returns the number fromvalues[raw.slice(1)]whenrawis a$-prefixed string, throws when the key is missing, otherwise returnsrawunchanged. Used internally by the other three exports and not commonly called directly by action / condition handlers.resolveAllParams(params, values)— bulk resolver. Builds a fresh object, walks every own key ofparamswith afor…inloop, and storesresolveParam(params[k], values)at the same key. Used byeffect-engine.tsfor the telemetry log payload (so the[EFFECT]line shows post-substitution numbers) and re-exported throughactions.ts.resolveNumber(raw, values)— number-typed resolver. CallsresolveParamthen assertstypeof === 'number'. The dominant entry point in the codebase: condition handlers (conditions.ts—chance,value,minLevel,threshold,seconds,tier) and action handlers (actions.ts—damage,radius,duration,value,count,speed,lifetime,spread,force,damageMult, etc.) call it on every numeric param.resolveString(raw, values)— string-typed resolver. CallsresolveParamthenString(v). Used for tag, signal-name, status-type, stat-id, source-owner, and color params (actions.ts—stat,source,signal,status,color,upgradeId;conditions.ts—value,tag).
Pattern notes
- The
$check israw.charCodeAt(0) === 36rather thanraw.startsWith('$')orraw[0] === '$'. Same semantics, slightly cheaper — this code is on the per-frame tick path for every action param of every effect that fires. $varsubstitution is the only way to thread per-tier / per-level scaling values into effect params. The author of anEffectDefwritesparams: { damage: "$dmg", radius: "$r" }once, and the loader that registers the effect at tier 2 bindsvalues: { dmg: 75, r: 240 }onto the instance. The same def can be registered at multiple tiers without duplication.- Resolution happens on every fire, not once at registration. Callers always pass
inst.valuesfrom the liveEffectInstance. This keeps the resolver pure and lets callers updatevaluesbetween fires if needed. - The four exports are re-surfaced through
engine/effects/index.tsso consumers can import them asimport { resolveNumber } from '../engine/effects'without reaching into the file directly. - All three typed wrappers go through
resolveParamrather than re-implementing the$check, so the “missing key throws” contract is enforced in exactly one place. - The
?? <default>idiom at call sites —resolveNumber(cond.params.chance ?? 1, inst.values)— supplies the default before the resolver runs. A$varthat resolves to a defined number is used as-is; a missingparamskey falls back to the literal default and passes through unchanged. A$varwhose key is missing fromvaluesis the error case and throws. resolveAllParamsis currently used only by the telemetry-log path ineffect-engine.ts. Action and condition handlers prefer the typedresolveNumber/resolveStringwrappers because they know the expected type per param.