Learn-skills.dev add-mod-resolver

Use when adding new push* resolver functions inside resolveModsForOffenseSkill to handle mod-based combat mechanics like tangles, debuffs, or conditional damage bonuses (project)

install
source · Clone the upstream repo
git clone https://github.com/NeverSight/learn-skills.dev
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/aclinia/torchlight-of-building/add-mod-resolver" ~/.claude/skills/neversight-learn-skills-dev-add-mod-resolver && rm -rf "$T"
manifest: data/skills-md/aclinia/torchlight-of-building/add-mod-resolver/SKILL.md
source content

Adding Mod Resolvers

Overview

Mod resolvers are

push*
functions defined inside
resolveModsForOffenseSkill
in
src/tli/calcs/offense.ts
. They read from the
mods
array (and optionally
config
,
prenormMods
,
resourcePool
,
defenses
) and push new derived mods based on game mechanics. Each resolver handles one mechanic (e.g., frostbite, numbed, tangles, infiltrations).

When to Use

  • Adding a new combat mechanic that derives mods from existing mods or configuration
  • Adding conditional damage bonuses based on presence of a flag mod (e.g.,
    IsTangle
    ,
    WindStalker
    )
  • Adding enemy debuff calculations (e.g., numbed stacks, frostbite, frail)
  • Adding buff/aura effect calculations with effect multipliers

Project File Locations

PurposeFile Path
Resolver function location
src/tli/calcs/offense.ts
(inside
resolveModsForOffenseSkill
)
Mod type definitions
src/tli/mod.ts
(
ModDefinitions
interface)
Configuration interface & defaults
src/tli/core.ts
(
Configuration
,
DEFAULT_CONFIGURATION
)
Mod helper utilities
src/tli/calcs/mod-utils.ts
Tests
src/tli/calcs/offense.test.ts

Architecture

applyModFilters
preprocesses all mods into four groups:

  • mods
    — non-per mods without
    condThreshold
    or
    resolvedCond
    (ready to use immediately)
  • prenormMods
    — all mods without
    resolvedCond
    (source for per-stackable normalization)
  • condThresholdMods
    — non-per mods with
    condThreshold
    (pushed back by
    normalize()
    when threshold is met)
  • resolvedCondMods
    — mods with
    resolvedCond
    (pushed back by individual
    push*
    resolvers when condition is met)

IMPORTANT: Mods with a

per
field are filtered into
prenormMods
only — they do NOT appear in
mods
.
The
per
field (from
ModBase
) triggers automatic per-stackable normalization via
normalize()
. If a mod needs custom resolver logic (e.g., applying an effect multiplier before scaling by stacks), do NOT use
per
on the mod type. Instead, store the scaling info in a custom field (e.g.,
perFervorAmt: number
) so the mod stays in
mods
and the resolver can find it via
filterMods()
.

resolveModsForOffenseSkill
then runs a sequence of
push*
resolver functions that push new derived mods into
mods
via
pm()
. Each
push*
function is a closure that captures:

  • mods: Mod[]
    — the shared mutable array of resolved mods
  • prenormMods
    — mods needing per-stackable normalization
  • condThresholdMods
    — mods held back until their stackable threshold is evaluated by
    normalize()
  • resolvedCondMods
    — mods held back until their resolved condition is evaluated by a
    push*
    resolver
  • config: Configuration
    — user-configured combat parameters
  • resourcePool
    — stats, mana, blessings, etc.
  • defenses
    — armor, evasion, resistances, etc.
  • loadout: Loadout
    — full parsed loadout
  • Helper functions:
    pm()
    (push mods),
    normalize()
    (normalize stackables),
    step()
    (dependency tracking)

The execution sequence begins with

normalizeFromConfig()
, which normalizes all stackables whose values come purely from
config
fields (e.g.,
level
,
num_enemies_nearby
,
enemy_numbed_stacks
). This runs before
pushStatNorms()
and all other resolvers. Normalize calls whose values depend on mods, stats, defenses, or resourcePool remain in their respective
push*
functions or inline in the execution sequence.

Available Helpers

From closure (defined in

resolveModsForOffenseSkill
):

  • pm(...ms: Mod[])
    — shorthand for
    mods.push(...ms)
  • normalize(stackable, value)
    — normalizes per-stackable mods from
    prenormMods
    and pushes satisfied
    condThresholdMods
    for that stackable
  • normalizeFromConfig()
    — calls
    normalize()
    for all stackables whose values come purely from
    config
    fields; called once at the start of the execution sequence before
    pushStatNorms()
  • step(stepName)
    — registers a step for dependency tracking (only needed if other steps depend on this one)
  • resolvedCondMods: Mod[]
    — mods with
    resolvedCond
    , separated out by
    applyModFilters
    ; push matching ones into
    mods
    via
    pm()
    when the condition is met
  • condThresholdMods: Mod[]
    — non-per mods with
    condThreshold
    , separated out by
    applyModFilters
    ; pushed back automatically by
    normalize()
    when their stackable threshold is met

From

src/tli/calcs/mod-utils.ts
:

  • modExists(mods, "ModType")
    — returns
    boolean
    , checks if any mod of that type exists
  • findMod(mods, "ModType")
    — returns first mod of type or
    undefined
  • filterMods(mods, "ModType")
    — returns all mods of type as
    ModT<T>[]
  • sumByValue(mods)
    — sums
    .value
    of all mods in array
  • calcEffMult(mods, "ModType")
    — calculates
    (1 + sum_of_inc) * product_of_addn
    multiplier
  • multModValue(mod, multiplier)
    — returns new mod with
    .value
    multiplied

Implementation Checklist

1. Ensure the Trigger Mod Type Exists

Check

src/tli/mod.ts
under
ModDefinitions
. If the flag mod (e.g.,
IsTangle
) doesn't exist, add it:

// In ModDefinitions
IsTangle: object;  // flag mod, no fields

For mods with data:

NumbedEffPct: { value: number };

2. Ensure Configuration Fields Exist (if needed)

If the resolver needs user-configurable values, ensure they exist in

src/tli/core.ts
. Use the
/add-configuration
skill if they don't.

3. Write the push* Function

Define the function inside

resolveModsForOffenseSkill
, near related resolvers. Follow these patterns:

Simple flag-based resolver (multiplies damage by a config value):

const pushTangle = (): void => {
  if (!modExists(mods, "IsTangle") || config.numActiveTangles <= 1) return;
  mods.push({
    type: "DmgPct",
    dmgModType: "global",
    addn: true,
    value: (config.numActiveTangles - 1) * 100,
    src: "Tangle",
  });
};

Debuff with effect multiplier:

const pushNumbed = (): void => {
  if (!config.enemyNumbed) return;
  const numbedStacks = config.enemyNumbedStacks ?? 10;
  const numbedEffMult = calcEffMult(mods, "NumbedEffPct");
  const baseValPerStack = 5;
  const numbedVal = baseValPerStack * numbedEffMult * numbedStacks;
  mods.push({
    type: "DmgPct",
    value: numbedVal,
    dmgModType: "lightning",
    addn: true,
    isEnemyDebuff: true,
    src: "Numbed",
  });
};

Buff with effect multiplier (e.g., aggression, mark):

const pushMark = (): void => {
  if (!config.targetEnemyMarked) return;
  const markEffMult = calcEffMult(mods, "MarkEffPct");
  const baseValue = 20;
  mods.push({
    type: "CritDmgPct",
    value: baseValue * markEffMult,
    addn: true,
    modType: "global",
    isEnemyDebuff: true,
    src: "Mark",
  });
};

Resolver with per-stackable normalization:

const pushPactspirits = () => {
  const addedMaxStacks = sumByValue(filterMods(mods, "MaxPureHeartStacks"));
  const maxStacks = 5 + addedMaxStacks;
  const stacks = config.pureHeartStacks ?? maxStacks;
  normalize("pure_heart", stacks);
};

Config-only normalization (add to

normalizeFromConfig
):

If the stackable value comes purely from

config
fields (no dependency on mods, stats, defenses, or resourcePool), add the
normalize()
call inside
normalizeFromConfig()
instead of creating a separate push* function or placing it inline in the execution sequence:

const normalizeFromConfig = (): void => {
  // ... existing config-based normalizes ...
  normalize("new_stackable", config.newStackableValue ?? defaultValue);
};

Only use a separate

push*
function or inline
normalize()
when the value depends on computed data (mods, stats, etc.), or when the value has a config override with a mod-computed fallback (e.g.,
config.stacks ?? maxStacksFromMods
).

Resolved condition resolver (conditions that depend on calculated values):

Some mod conditions can't be evaluated statically from configuration — they depend on values calculated earlier in

resolveModsForOffenseSkill
(e.g., sealed mana/life percentages come from
resourcePool.sealedResources
, not config). These use
resolvedCond
on the mod (see
ResolvedCondition
in
mod.ts
) instead of
cond
(which is for static
Configuration
-based conditions evaluated in
filterModsByCond
).

Mods with

resolvedCond
are separated out by
applyModFilters
into
resolvedCondMods
. The push* resolver filters for its condition and pushes matching mods into
mods
via
pm()
when the condition is met.

const pushHasSealedLifeAndManaCond = (): void => {
  const { sealedManaPct, sealedLifePct } = resourcePool.sealedResources;
  if (sealedManaPct <= 0 || sealedLifePct <= 0) return;
  pm(
    ...resolvedCondMods.filter(
      (m) => m.resolvedCond === "have_both_sealed_mana_and_life",
    ),
  );
};

To add a new resolved condition:

  1. Add the condition string to
    ResolvedConditions
    in
    src/tli/mod.ts
  2. In the mod parser template (
    src/tli/mod-parser/templates.ts
    ), use
    resolvedCond: "condition_name"
    instead of
    cond: "condition_name"
  3. Write a
    push*
    resolver that filters
    resolvedCondMods
    and pushes matching mods via
    pm()
    , and call it at the appropriate point in the execution sequence

Resolver with step dependencies (when one resolver produces mods consumed by another):

Use

step()
and
stepDeps
whenever a resolver pushes mods that another resolver later reads. For example,
pushFervor
generates
SkillAreaPct
mods, so
pushSkillArea
depends on it. The dependency graph is validated at test time — if
pushSkillArea
runs before
pushFervor
, an error is recorded.

  1. Register both steps and their dependency in
    stepDeps
    (above
    resolveModsForOffenseSkill
    ):
const stepDeps = createSelfReferential({
  // ... existing steps ...
  fervor: [],
  skillArea: ["fervor"],  // skillArea must run after fervor
});
  1. Call
    step()
    at the top of each resolver:
const pushFervor = () => {
  step("fervor");
  if (resourcePool.hasFervor) {
    mods.push(calculateFervorCritRateMod(mods, resourcePool));
    mods.push(...calculateFervorBaseEffSkillArea(mods, resourcePool));
    normalize("fervor", resourcePool.fervorPts);
  }
};

const pushSkillArea = (): void => {
  step("skillArea");
  const skillAreaPct = sumByValue(filterMods(mods, "SkillAreaPct"));
  normalize("skill_area", skillAreaPct);
};
  1. Ensure the call order in the execution sequence matches the dependency graph (dependent runs after dependency):
pushFervor();    // must come first
pushSkillArea(); // depends on fervor

step()
always goes at the top of the resolver, before any early returns, so the step is registered even if the resolver short-circuits.

4. Call the Function

Add the call in the execution sequence inside

resolveModsForOffenseSkill
. Place it near related mechanics.

5. Verify

pnpm test
pnpm typecheck
pnpm check

Common Patterns

PatternWhen to UseKey Helper
Check flag mod existsMechanic only applies when a specific support/skill mod is present
modExists(mods, "FlagMod")
Check config booleanMechanic depends on user toggle
if (!config.someToggle) return
Effect multiplierBuff/debuff has mods that scale its effectiveness
calcEffMult(mods, "SomeEffPct")
Config stacks with defaultUser can override stack count, defaults to max
config.someStacks ?? maxStacks
Normalize stackableMechanic involves per-stackable scaling with computed value
normalize("stackable_name", value)
Config-only normalizeStackable value comes purely from configAdd to
normalizeFromConfig()
Filter by resolved conditionCondition depends on calculated values, not static config
pm(...resolvedCondMods.filter(...))
addn: true
on DmgPct
More multiplier (multiplicative with other
addn: true
mods)
addn: false
on DmgPct
Increased multiplier (additive with other
addn: false
mods)
isEnemyDebuff: true
Damage increase from enemy debuff (for display grouping)
src: "Name"
Label for debug/display panel

DmgPct addn Field

The

addn
(additional) field on
DmgPct
controls how the damage bonus stacks:

  • addn: false
    Increased damage. All
    addn: false
    mods sum together into one multiplier:
    (1 + sum)
    .
  • addn: true
    More damage. Each
    addn: true
    mod is its own separate multiplier:
    (1 + value1) * (1 + value2) * ...

Most resolvers use

addn: true
because their effects are multiplicative with other damage sources.

Common Mistakes

MistakeFix
Forgetting early return when flag/config is absentAlways guard with
if (!condition) return
Using
addn: false
when the mechanic should be multiplicative
Use
addn: true
for separate "more" multipliers
Pushing mods without
src
Always include
src
for debug panel visibility
Forgetting to add config fieldUse
/add-configuration
skill first
Missing
step()
when a resolver produces mods consumed by another
Add both steps to
stepDeps
with the dependency, and call
step()
at the top of each resolver
Not matching execution order to
stepDeps
The call order must satisfy the dependency graph — dependent resolvers run after their dependencies
Not handling undefined config with
??
Optional config values need fallback:
config.stacks ?? defaultMax
Placing config-only normalize inline in execution sequenceAdd to
normalizeFromConfig()
instead; only use inline/push* for computed values
Using
per
on a mod that needs custom resolver logic
Mods with
per
go to
prenormMods
, not
mods
, so
filterMods(mods, ...)
won't find them. Use a custom field (e.g.,
perFervorAmt: number
) instead so the mod stays in
mods
for the resolver to read