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)
git clone https://github.com/NeverSight/learn-skills.dev
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"
data/skills-md/aclinia/torchlight-of-building/add-mod-resolver/SKILL.mdAdding 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
| Purpose | File Path |
|---|---|
| Resolver function location | (inside ) |
| Mod type definitions | ( interface) |
| Configuration interface & defaults | (, ) |
| Mod helper utilities | |
| Tests | |
Architecture
applyModFilters preprocesses all mods into four groups:
— non-per mods withoutmods
orcondThreshold
(ready to use immediately)resolvedCond
— all mods withoutprenormMods
(source for per-stackable normalization)resolvedCond
— non-per mods withcondThresholdMods
(pushed back bycondThreshold
when threshold is met)normalize()
— mods withresolvedCondMods
(pushed back by individualresolvedCond
resolvers when condition is met)push*
IMPORTANT: Mods with a
field are filtered into per
only — they do NOT appear in prenormMods
. The mods
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:
— the shared mutable array of resolved modsmods: Mod[]
— mods needing per-stackable normalizationprenormMods
— mods held back until their stackable threshold is evaluated bycondThresholdModsnormalize()
— mods held back until their resolved condition is evaluated by aresolvedCondMods
resolverpush*
— user-configured combat parametersconfig: Configuration
— stats, mana, blessings, etc.resourcePool
— armor, evasion, resistances, etc.defenses
— full parsed loadoutloadout: Loadout- Helper functions:
(push mods),pm()
(normalize stackables),normalize()
(dependency tracking)step()
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):
— shorthand forpm(...ms: Mod[])mods.push(...ms)
— normalizes per-stackable mods fromnormalize(stackable, value)
and pushes satisfiedprenormMods
for that stackablecondThresholdMods
— callsnormalizeFromConfig()
for all stackables whose values come purely fromnormalize()
fields; called once at the start of the execution sequence beforeconfigpushStatNorms()
— registers a step for dependency tracking (only needed if other steps depend on this one)step(stepName)
— mods withresolvedCondMods: Mod[]
, separated out byresolvedCond
; push matching ones intoapplyModFilters
viamods
when the condition is metpm()
— non-per mods withcondThresholdMods: Mod[]
, separated out bycondThreshold
; pushed back automatically byapplyModFilters
when their stackable threshold is metnormalize()
From
src/tli/calcs/mod-utils.ts:
— returnsmodExists(mods, "ModType")
, checks if any mod of that type existsboolean
— returns first mod of type orfindMod(mods, "ModType")undefined
— returns all mods of type asfilterMods(mods, "ModType")ModT<T>[]
— sumssumByValue(mods)
of all mods in array.value
— calculatescalcEffMult(mods, "ModType")
multiplier(1 + sum_of_inc) * product_of_addn
— returns new mod withmultModValue(mod, multiplier)
multiplied.value
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:
- Add the condition string to
inResolvedConditionssrc/tli/mod.ts - In the mod parser template (
), usesrc/tli/mod-parser/templates.ts
instead ofresolvedCond: "condition_name"cond: "condition_name" - Write a
resolver that filterspush*
and pushes matching mods viaresolvedCondMods
, and call it at the appropriate point in the execution sequencepm()
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.
- Register both steps and their dependency in
(abovestepDeps
):resolveModsForOffenseSkill
const stepDeps = createSelfReferential({ // ... existing steps ... fervor: [], skillArea: ["fervor"], // skillArea must run after fervor });
- Call
at the top of each resolver:step()
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); };
- 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
| Pattern | When to Use | Key Helper |
|---|---|---|
| Check flag mod exists | Mechanic only applies when a specific support/skill mod is present | |
| Check config boolean | Mechanic depends on user toggle | |
| Effect multiplier | Buff/debuff has mods that scale its effectiveness | |
| Config stacks with default | User can override stack count, defaults to max | |
| Normalize stackable | Mechanic involves per-stackable scaling with computed value | |
| Config-only normalize | Stackable value comes purely from config | Add to |
| Filter by resolved condition | Condition depends on calculated values, not static config | |
on DmgPct | More multiplier (multiplicative with other mods) | — |
on DmgPct | Increased multiplier (additive with other mods) | — |
| Damage increase from enemy debuff (for display grouping) | — |
| Label for debug/display panel | — |
DmgPct addn Field
The
addn (additional) field on DmgPct controls how the damage bonus stacks:
— Increased damage. Alladdn: false
mods sum together into one multiplier:addn: false
.(1 + sum)
— More damage. Eachaddn: true
mod is its own separate multiplier:addn: true(1 + value1) * (1 + value2) * ...
Most resolvers use
addn: true because their effects are multiplicative with other damage sources.
Common Mistakes
| Mistake | Fix |
|---|---|
| Forgetting early return when flag/config is absent | Always guard with |
Using when the mechanic should be multiplicative | Use for separate "more" multipliers |
Pushing mods without | Always include for debug panel visibility |
| Forgetting to add config field | Use skill first |
Missing when a resolver produces mods consumed by another | Add both steps to with the dependency, and call at the top of each resolver |
Not matching execution order to | The call order must satisfy the dependency graph — dependent resolvers run after their dependencies |
Not handling undefined config with | Optional config values need fallback: |
| Placing config-only normalize inline in execution sequence | Add to instead; only use inline/push* for computed values |
Using on a mod that needs custom resolver logic | Mods with go to , not , so won't find them. Use a custom field (e.g., ) instead so the mod stays in for the resolver to read |