d2r-affix-lib
v0.2.4
Published
Bundled D2R affix data (ranges, tiers, contributors) for community item tools.
Maintainers
Readme
d2r-affix-lib
Bundled D2R affix data for community item tools (item valuators, triage helpers, card-assemblers). Provides authoritative roll ranges and per-affix contributors so consumers don't have to hand-compile this data from magicprefix.txt / magicsuffix.txt.
Part of the D2R Tools project — a constellation of community tools for Diablo II: Resurrected.
Status: v0.2.4. Coverage: ten item types —
ring,scharm,lcharm,gcharm,circlet,boots,amulet,gloves,belt,jewel. Crafts are first-class on rings, boots, amulets, gloves, and belts (Quality.CRAFT_BLOOD/CRAFT_CASTER/CRAFT_HITPOWER/CRAFT_SAFETY). Both forward-compat craft patterns are now live:cardRangeGappopulated by caster-amulet FCR (v0.2.1, discontinuous{5..20}with gap{11..14}), and craft-only stats populated by glovecrushing_blow(v0.2.2, empty rare pool with a populatedcraftBonusessidecar). The full design rationale, response shapes, card-range derivation rules, and open questions live indocs/SPEC.md; craft semantics get their own deep-dive indocs/CRAFTS-DESIGN.md.
Install
npm install d2r-affix-libQuick start
import {
getAbilityForItem,
Abilities,
ItemTypes,
Quality,
SkillTabs,
} from "d2r-affix-lib";
// Non-parametric — get the rolled-up range plus every contributing affix.
getAbilityForItem(ItemTypes.SCHARM, Abilities.LIFE);
// → {
// parametric: false,
// cardRange: { min: 5, max: 20 },
// contributors: [
// { affixId: 349, name: "of Life", kind: "suffix", group: 26, alvl: 1, levelreq: 14, min: 5, max: 10, primary: true, frequency: 4 },
// { affixId: 350, name: "of Substinence", kind: "suffix", group: 26, alvl: 23, levelreq: 17, min: 11, max: 15, primary: true, frequency: 4 },
// { affixId: 351, name: "of Vita", kind: "suffix", group: 26, alvl: 47, levelreq: 39, min: 16, max: 20, primary: true, frequency: 4 },
// ],
// }
// Parametric — fetch all 20 skill-tab variants on a circlet.
const tabs = getAbilityForItem(ItemTypes.CIRCLET, Abilities.PLUS_SKILL_TAB);
// → { parametric: true, paramKind: "skilltab", variants: [...20...] }
// Filter to a specific tab + ask for magic-quality (the +3 magic-only tier
// rolls only on magic circlets).
getAbilityForItem(ItemTypes.CIRCLET, Abilities.PLUS_SKILL_TAB, {
filter: { kind: "tab", tab: SkillTabs.AMA_PASSIVE_AND_MAGIC },
quality: Quality.MAGIC,
});
// → { parametric: true, paramKind: "skilltab", variants: [
// { param: { tab: "ama_passive_and_magic" },
// cardRange: { min: 1, max: 3 }, // includes Athlete's (+3 magic-only)
// contributors: [Acrobat's, Gymnast's, Athlete's] }
// ] }
// Same query with Quality.RARE — Athlete's is filtered out.
getAbilityForItem(ItemTypes.CIRCLET, Abilities.PLUS_SKILL_TAB, {
filter: { kind: "tab", tab: SkillTabs.AMA_PASSIVE_AND_MAGIC },
quality: Quality.RARE,
});
// → cardRange.max === 2
// Crafts (new in v0.2.0) — RARE surfaces craftBonuses sidecar:
getAbilityForItem(ItemTypes.RING, Abilities.LIFE_LEECH);
// → {
// parametric: false,
// cardRange: { min: 3, max: 8 }, // rare-pool envelope
// contributors: [...the three rare LL tiers...],
// craftBonuses: [{ craft: "blood", min: 1, max: 3 }], // ← new
// }
// Same ability under Quality.CRAFT_BLOOD returns the projected view:
getAbilityForItem(ItemTypes.RING, Abilities.LIFE_LEECH, {
quality: Quality.CRAFT_BLOOD,
});
// → cardRange = { min: 1, max: 11 } ← composed: craft.min .. rare.max + craft.max
// Returns null when the ability cannot roll on the given item type, when
// a filter doesn't match any variant, or when a CRAFT_* mode doesn't apply.
getAbilityForItem(ItemTypes.SCHARM, Abilities.PLUS_CLASS_SKILLS);
// → null
getAbilityForItem(ItemTypes.SCHARM, Abilities.LIFE, {
quality: Quality.CRAFT_BLOOD,
});
// → null (charms can't be crafted)Coverage (v0.2.4)
| Item type | Slug | In v0.2.4? | Crafts? |
| --------------------- | --------- | -------------------- | ------- |
| Rings | ring | ✓ | ✓ |
| Small charms (1×1) | scharm | ✓ | — |
| Large charms (1×2) | lcharm | ✓ | — |
| Grand charms (1×3) | gcharm | ✓ | — |
| Circlets | circlet | ✓ | — |
| Boots | boots | ✓ | ✓ |
| Amulets | amulet | ✓ | ✓ |
| Gloves | gloves | ✓ | ✓ |
| Belts | belt | ✓ | ✓ |
| Jewels | jewel | ✓ | — |
| All other slots | — | not yet | |
Charms and jewels are magic + rare quality (charms are magic-only by game rules, jewels are dual-quality), and neither can be crafted; circlets aren't a Horadric Cube craft input. All three correctly return null for any CRAFT_* query.
Quality
options.quality controls which roll-stack the cardRange and contributor list reflect:
| Value | What you get |
|---|---|
| Quality.RARE (default) | Rare-quality items: up to 3 prefixes + 3 suffixes (one tier per group, summed). Magic-only tiers are filtered out. When one or more crafts contribute to the queried ability, a craftBonuses? sidecar lists each applicable craft's min/max addendum. |
| Quality.MAGIC | Magic-quality items: 1 prefix + 1 suffix max, full pool (including magic-only tiers). No craftBonuses (magic items can't be crafted). |
| Quality.CRAFT_BLOOD / CRAFT_CASTER / CRAFT_HITPOWER / CRAFT_SAFETY | Crafted-rare items. Returns the projected view — the rare-pool envelope composed with the craft's fixed bonus. Discontinuous projections (when present) carry a cardRangeGap? field marking unreachable values inside the outer envelope. |
Charms are magic-only by game rules. Quality.RARE on a charm collapses to Quality.MAGIC for ergonomic forgiveness; Quality.CRAFT_* on a charm returns null.
The full craft-mode design — sidecar vs. projection rationale, worked examples (ring LL contiguous, amulet FCR discontinuous), and migration notes — lives in docs/CRAFTS-DESIGN.md.
Integration patterns
Crafts give consumers two integration paths. Pick based on whether you already know the craft mode of the item you're reasoning about.
Path A — "What's possible?" (don't know the craft, or showing a generic range)
Use Quality.RARE (the default) and read both cardRange and the craftBonuses sidecar. This is the right path for triage UIs, OCR pipelines that haven't yet identified the craft, and any "show me the legal range" display.
// User entered "+6% Life Leech" on a ring. Is that valid?
const r = getAbilityForItem(ItemTypes.RING, Abilities.LIFE_LEECH);
// → cardRange: { min: 3, max: 8 }
// craftBonuses: [{ craft: "blood", min: 1, max: 3 }]
const value = 6;
// Reachable on a non-crafted rare:
const reachableOnRare = value >= r.cardRange.min && value <= r.cardRange.max;
// → true (3 ≤ 6 ≤ 8)
// Reachable on a blood-crafted rare (rare stack + craft addendum):
const blood = r.craftBonuses?.find((cb) => cb.craft === "blood");
const reachableOnBloodCraft =
blood !== undefined &&
value >= blood.min && // craft is always present
value <= r.cardRange.max + blood.max; // stacked ceiling
// → true (1 ≤ 6 ≤ 11)If you only need the rare-pool answer (no crafting in your domain), ignore craftBonuses and you're done. The sidecar is informationally additive; nothing about the primary cardRange or contributors shifts because of it.
Craft-only stats — some abilities (e.g. REGENERATE_MANA on rings) only roll via crafts. Under default RARE the response shape is:
const r = getAbilityForItem(ItemTypes.RING, Abilities.REGENERATE_MANA);
// → cardRange: { min: 0, max: 0 } ← no rare-pool contribution
// contributors: [] ← none
// craftBonuses: [{ craft: "caster", min: 4, max: 10 }]So the rule is simple: the answer is wherever it has range. If cardRange is {0,0} and contributors is empty, look in craftBonuses (or use Quality.CRAFT_* to promote the craft into the cardRange directly — see Path B).
Path B — "Is this specific craft valid?" (the craft mode is known)
Use Quality.CRAFT_BLOOD / CRAFT_CASTER / CRAFT_HITPOWER / CRAFT_SAFETY. The lib does the projection internally — cardRange is the union envelope of "craft alone" and "rare + craft" outcomes — and surfaces cardRangeGap when there's a hole inside the envelope. One call yields a correct-by-construction validator.
// User confirmed: this is a blood-crafted rare ring with +6% LL.
const r = getAbilityForItem(ItemTypes.RING, Abilities.LIFE_LEECH, {
quality: Quality.CRAFT_BLOOD,
});
// → cardRange: { min: 1, max: 11 } ← projected envelope
// cardRangeGap: undefined ← contiguous, no hole
const value = 6;
const inGap =
r.cardRangeGap !== undefined &&
value >= r.cardRangeGap.min &&
value <= r.cardRangeGap.max;
const valid =
!inGap && value >= r.cardRange.min && value <= r.cardRange.max;
// → true. Exhaustive by construction — no "did I remember to add craft?"Always check cardRangeGap even if you "expect" the projection to be contiguous on your data — it's the explicit signal for discontinuous ranges, and forgetting it on (e.g.) a caster amulet would silently accept unreachable values:
// Live in v0.2.1: caster amulet FCR is the canonical discontinuous case.
// of the Apprentice (rare): +10
// craft addendum (caster): +5..10
// stacked (rare + craft): +15..20
// union: {5..10} ∪ {15..20}
// ↳ values 11-14 are unreachable
const r = getAbilityForItem(ItemTypes.AMULET, Abilities.FASTER_CAST_RATE, {
quality: Quality.CRAFT_CASTER,
});
// → cardRange: { min: 5, max: 20 }
// cardRangeGap: { min: 11, max: 14 } ← always check thisA craft mode that doesn't apply to the queried (itype, ability) returns null rather than a zeroed shape — call it on Quality.CRAFT_HITPOWER for ring MANA and you get null directly. Charms and circlets (which can't be crafted) return null for any CRAFT_* query, with no per-itype branching needed on your side.
Choosing between the two paths
| Use Path A (RARE default + sidecar) when… | Use Path B (CRAFT_* projection) when… |
|---|---|
| You're showing a generic "valid range" or contributor list. | You're scoring a specific item whose craft mode is known. |
| You haven't yet identified whether the item is crafted (e.g. early in an OCR pipeline). | You want a single call that returns a correct-by-construction validator. |
| You want to display "rare ceiling: 8, blood adds +1..3" in the UI. | You want one cardRange plus an optional cardRangeGap. |
| Your domain has no concept of crafted items. | You've already filtered down to "this is a blood ring." |
Both paths share the same underlying data — the difference is whether the lib does the composition for you (Path B) or hands you the primitives so you can compose conditionally (Path A).
Contributor fields
Each Contributor returned in a non-parametric result (or inside a parametric variant) carries:
| Field | Meaning |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| affixId | 1-based line number in magicprefix.txt / magicsuffix.txt. Stable id; the canonical reference for this contributor. |
| name | Display name from the txt row (e.g. "Bronze", "of the Sun"). |
| kind | "prefix" or "suffix". |
| group | The group column from the txt — affixes in the same group are mutually exclusive on a single item. |
| alvl | Affix level — gates which item ilvl can roll this tier. |
| levelreq | Character level required to wear an item carrying this affix. |
| min/max | This contributor's min/max contribution to the queried ability. |
| primary | true when the ability is a user-controlled axis on the affix; false for side-effect contributions (e.g. the +15 AR riding along on of Light). |
| frequency | Spawn weight inside the contributor's group. Useful for odds math. |
Versioning
Bundled JSON means semver here governs the data layer plus the API surface:
| Bump | Trigger |
|---|---|
| patch | Data corrections, new item types, new ability ids exposed via existing methods. Anything additive at the data layer that doesn't change the API surface. |
| minor | New methods (getAffix()), new options on existing methods (options.craft), new public exports. Things that expand what consumers can call. |
| major | Breaking changes (id renames, response-shape changes, removed item types). |
Pinning ^0.1.0 is safe and gets you every itype expansion automatically. You only need to widen your range when the API actually changes shape (a minor or major bump).
License
PolyForm Noncommercial License 1.0.0 — copyright © 2026 Alex Beverage.
Free to use, modify, and redistribute for any noncommercial purpose — community tools, hobby projects, research, personal use, and use by educational/charitable/government organizations are all explicitly permitted, regardless of whether the consuming application itself is open- or closed-source.
For commercial use (including monetized products, paid services, or any use intended for commercial advantage), a separate commercial license is required. Open an issue on the repository or reach out on the #the-hellforge channel of the Diablo II: Resurrected Discord.
