Skip to main content

Python bindings for the PoE2 crafting simulator

Project description

crafting-sim

crafting_sim is a deterministic Path of Exile 2 crafting simulator: Python bindings for the Rust core that also powers the browser simulator, built for writing fast crafting simulations. All crafting game data is embedded in the compiled extension at build time; currency pricing ships no rates and is loaded from a local file or the network. Same seed → same outcomes, so crafts are reproducible and success rates measurable. crafting_sim.DATA_VERSION and crafting_sim.CATALOG_DIGEST identify the embedded game-data snapshot.

Install

pip install optimalexile-crafting-sim

PyPI ships Linux x86_64/aarch64 wheels for Python ≥ 3.13 only (no sdist), so other platforms cannot install from PyPI.

Discover, don't memorize

The library explains itself; query it instead of relying on game knowledge.

from crafting_sim import catalog
from crafting_sim.data import Base, Bone, Catalyst, Essence, Family, Liquid, Omen, Socketable

catalog.bases          # every craftable base: id, name, item class, tags
catalog.operations     # every currency/operation: name, description, kind
catalog.omens          # omens with full rule text
catalog.essences       # per-item-class guaranteed outcomes of each essence
catalog.liquids        # per-jewel guaranteed outcomes of each Delirium liquid
catalog.bones          # desecration rules: item classes, min modifier level
catalog.socketables    # rune effects per item class, and the tags they add
catalog.mod_pool(Base.SAPPHIRE_RING)   # what can roll on a base, with weights
catalog.modifier("FireResist8")        # one modifier: text, families, tags

Every crafting_sim.data enum member carries its in-game description as its docstring — help(Essence.ESSENCE_OF_THE_BODY) or member.__doc__ answers "what does this do?", including what an essence guarantees per item class. To answer it for a specific item class:

by_name = {e.name: e for e in catalog.essences}
effect = by_name["Essence of the Body"].effect_for_item_class("Ring")
effect.text                      # "+(60-69) to maximum Life" style summary
effect.mods                      # guaranteed modifier ids with weights

Core loop

from crafting_sim import Item, Tier, ops
from crafting_sim.data import Base, Family, Omen

item = Item(Base.SOLAR_AMULET, item_level=82, seed=42)
item.transmute().augment().regal().exalt()      # chainable currency verbs
item.chaos(tier=Tier.PERFECT)                   # tiered currencies take a Tier
item.mods, item.prefixes, item.open_suffixes    # inspect state
item.has_mod(Family.BASE_SPIRIT, tier=1)        # query by family/tier/slot/id
item.has_mod(Family.FIRE_RESISTANCE, min_tier=2)

item.can_apply(ops.exalted())                   # validity check, no mutation
dist = item.outcomes(ops.exalted())             # exact next-step distribution
dist.probability(lambda it: it.has_mod(Family.BASE_SPIRIT, tier=1))

item.set_active_omens([Omen.SINISTRAL_EXALTATION])  # omens are item state
branch = item.copy()  # exact clone, including the current random stream
item.undo()           # step back one operation
item.reset()          # back to the starting base, same seed
item.reseed(7)        # replay all steps under a different seed
item.currency_used    # Counter of every currency consumed so far

Override embedded modifier weights when testing corrected or experimental roll data. Changes affect future rolls only and are preserved by replay exports; an empty mapping restores the canonical weights:

item.set_mod_weight_overrides({"FireResist8": 25_000})
item.chaos()
item.set_mod_weight_overrides({})

Every currency has both a verb (item.essence(...)) and an ops constructor (ops.essence(...)) for can_apply/outcomes.

Everything is strongly typed. Small structural values such as Tier, Rarity, and Slot are integer-backed enums; bases, mod families, essences, liquids, omens, and catalysts are generated StrEnums under crafting_sim.data whose values are canonical game-data IDs. Item mutates in place and verbs chain; equal items have the same canonical state. currency_used derives from session history, so failed operations are not counted and copies retain their usage.

Recipes

Estimate success odds and cost empirically. Deterministic seeds turn any strategy function into a measurable experiment — this is the library's superpower over wiki math:

from collections import Counter
from crafting_sim import Item
from crafting_sim.data import Base, Family
from crafting_sim.pricing import poe2_currency_rates_from_env, value_currency_usage

rates = poe2_currency_rates_from_env()  # None unless OPTIMALEXILE_PRICES is set
used, hits, trials = Counter(), 0, 500
for seed in range(trials):
    item = Item(Base.SAPPHIRE_RING, item_level=82, seed=seed)
    item.alchemy()
    while not item.has_mod(Family.FIRE_RESISTANCE, tier=1):
        item.chaos()
    hits += 1
    used.update(item.currency_used)
print(hits / trials, value_currency_usage(used, rates).total_divines)

Start from a real item, then keep crafting it:

# The primary path: this library's own export JSON round-trips exactly.
payload = item.export_item()                # canonical JSON to persist/share
ingame = item.export_ingame_text()          # advanced text for Path of Building
item = Item.from_paste(payload, seed=7)     # restore and keep crafting
item = Item.from_text(game_text)            # in-game copy (Ctrl+Alt+C) format
item = Item.from_trade_listing(trade_json)  # trade-site listing JSON
# from_paste auto-detects all three; export_replay/from_replay additionally
# reproduce the full crafting history, not just the final state.
item.exalt()

Imports start a fresh session from the supplied seed (no history, usage, or Omens carry over). Item exports record their source data version for provenance, but compatible canonical IDs keep importing across versions. The same payload round-trips with the browser simulator's Copy item button.

Hand a session to the browsercrafting_sim.share compresses a replay into the share code the web simulator reads from the URL fragment:

from crafting_sim.share import decode_share, encode_share, session_url

code = encode_share(item.export_replay())
print(session_url(code))  # https://craft.optimalexile.com/#r=...

Item.from_paste(code)  # or read a shared code back, history included

Opening that URL resumes the exact session, history included. The share format lives in Rust, so codes move freely between Python and the browser.

Essence + omen rerolling — replacement essences remove a random mod before adding their guaranteed one; omens decide which side is removed:

# Perfect essences are item-class gated — check catalog.essences (or the
# enum docstring) before assuming one applies to your base.
item = Item(Base.CHAMPION_CUIRASS, item_level=82, seed=1).alchemy()
# Crystallisation omens (Perfect/Corrupted essences only) restrict the
# removal to one side: Sinistral = a prefix is replaced, Dextral = a suffix.
item.set_active_omens([Omen.SINISTRAL_CRYSTALLISATION])
item.essence(Essence.PERFECT_ESSENCE_OF_THE_BODY)  # suffixes survive
item.clear_omens()

# Free targeting: the removal must leave room for the guaranteed mod. When
# the essence guarantees a prefix and the prefixes are full, only a prefix
# can be removed — same protection as the omen, without paying for it:
item = Item(Base.SAPPHIRE_RING, item_level=82, seed=11).alchemy()
while item.open_prefixes or item.open_suffixes:
    item.exalt()                                       # 3 prefixes, 3 suffixes
item.essence(Essence.PERFECT_ESSENCE_OF_THE_MIND)      # guarantees a prefix
# no omens needed: every suffix is guaranteed to survive

Delirium liquids — replace one modifier on a Rare Basic or Time-Lost Jewel with the jewel-specific Crafted modifier described by catalog.liquids:

item = Item(Base.RUBY, item_level=82, seed=3).alchemy()
item.liquid(Liquid.POTENT_LIQUID_CONTEMPT)
# The Crafted result grants one additional prefix or suffix slot.
# Potent Liquid Ferocity instead scales explicit prefixes or suffixes.

Whittling / Erasure reroll — replace a targeted junk suffix without ever touching the rest. Chaos + Omen.WHITTLING removes the lowest-level modifier on the item; adding Omen.DEXTRAL_ERASURE (SINISTRAL_ for prefixes) first restricts the candidates to suffixes, so the pair removes the lowest-level suffix. Omens can be expensive, so buy only what the current state needs:

item = Item(Base.SAPPHIRE_RING, item_level=82, seed=5).alchemy()
while not item.has_mod(Family.FIRE_RESISTANCE, tier=1):
    mods = [mod for mod in item.mods if mod.slot is not None]
    junk = min((m for m in mods if m.slot == Slot.SUFFIX), key=lambda m: m.required_level)
    if junk.required_level < min(m.required_level for m in mods if m is not junk):
        omens = [Omen.WHITTLING]  # junk is the item's lowest mod: Whittling suffices
    else:
        omens = [Omen.DEXTRAL_ERASURE, Omen.WHITTLING]  # lowest within the suffixes
    item.set_active_omens(omens).chaos()

Removal omens are expensive, so check first whether the item's own shape already gives the guarantee. A plain annul() can only take a prefix when every suffix is fractured or absent — the kolrs_hunt_gloves example fractures its one irreplaceable suffix precisely so the rest of the craft never has to buy an Erasure omen at all.

Desecration reveal flow — a bone adds an unrevealed affix, revealed in a separate offer/choose step:

WANTED = "AbyssModArmourJewelleryKurgalSuffixColdChaosResistance"


def pick(offer):  # your goal predicate over the candidate ModViews
    return next((choice for choice in offer if choice.id == WANTED), None)


item = Item(Base.SAPPHIRE_RING, item_level=82, seed=2).alchemy()
while True:
    # Faction omens (e.g. Omen.THE_BLACKBLOODED guarantees Kurgal mods) gate
    # the offer pool; set them before desecrating.
    item.desecrate(Bone.PRESERVED_COLLARBONE)     # jewellery bone, Rare only
    slot = item.unrevealed_slots[0]               # Slot.PREFIX or Slot.SUFFIX
    item.set_active_omens([Omen.ABYSSAL_ECHOES])  # enables one offer reroll
    choices = item.reveal_offer(slot)             # three candidate ModViews
    target = pick(choices)
    if target is None:
        choices = item.reroll_offer()             # consumes the Abyssal Echoes
        target = pick(choices)
    if target is not None:
        item.reveal(target)                       # commit the candidate
        break
    # Miss. Viewing an offer locks the item until it is revealed, so take any
    # choice, then let Omen of Light strip the revealed Desecrated modifier
    # with the next Annulment and desecrate again.
    item.reveal(choices[0])
    item.set_active_omens([Omen.LIGHT]).annul()

Fracturing with a desecrated filler — the unrevealed desecrated affix counts toward the Fracturing Orb's four-modifier minimum but is never a fracture candidate:

item = Item(Base.SOLAR_AMULET, item_level=82, seed=3)
item.transmute().augment().regal()          # exactly three real mods
item.desecrate(Bone.PRESERVED_COLLARBONE)   # fourth, unrevealed, mod
item.fracture()                             # each real mod fractures at 1/3

Catalysing a slam toward a modifier typeOmen.CATALYSING_EXALTATION makes an Exalted Orb consume the item's catalyst quality to bias its roll toward that catalyst's modifier type. Apply the type you want, slam, reapply, and convert the quality to whatever the finished item should carry:

item.set_active_omens([Omen.SINISTRAL_CRYSTALLISATION])
item.essence(Essence.ESSENCE_OF_THE_BREACH)   # Maximum Quality 20 -> 40
item.catalyst(Catalyst.NEURAL)                # one call fills to the cap
item.set_active_omens([Omen.CATALYSING_EXALTATION, Omen.SINISTRAL_EXALTATION])
item.exalt(tier=Tier.PERFECT)                 # ~5% -> ~23% for max Mana T2+

One catalyst() call takes the quality straight to the cap and spends a catalyst per point, so switching type later costs no more than the first application. A catalyst already at the cap is refused (can_apply returns False, catalyst() raises). The quality is consumed by the slam, so reapply before each one. Essence of the Breach leaves a required-level-1 modifier behind, which Omen.WHITTLING can strip once the craft is done — see the kurgal_solar_amulet example.

Socketablessocket() consumes the currency; the set_socketable/ add_socket edits reshape sockets for free when modeling an item instead:

item = Item(Base.SIRENSCALE_GLOVES, item_level=82, seed=4)
item.add_socket()                          # free edit: create the socket first
item.socket(Socketable.IRON_RUNE, slot=0)  # consumes the rune currency
item.set_socketable(Socketable.DESERT_RUNE, slot=0)  # free swap when modeling

Affixes gated by socketables — warping runes like Kolr's Hunt add an item tag (here marksman) while socketed, and that tag unlocks an extra affix pool the base cannot otherwise roll:

item = Item(Base.SIRENSCALE_GLOVES, item_level=81, rune_sockets=2, seed=6)
item.socket(Socketable.KOLR_S_HUNT, slot=0)   # adds the "marksman" tag
item.transmute().augment().regal()
while not item.has_mod(id="MarksmanInfluenceProjectileSpeed3"):
    item.chaos()               # influence mods roll like any explicit mod —
                               # chaos, exalt, desecration reveals, fracturing
item.set_socketable(None, slot=0)
item.has_mod(id="MarksmanInfluenceProjectileSpeed3")  # True: rolled mods stay

catalog.mod_pool(Base.SIRENSCALE_GLOVES) lists these MarksmanInfluence* mods with their weights, but without the rune's tag their spawn chance is zero — entry.unlock_tag marks them, and catalog.socketables says which rune supplies the tag for a given item class. The kolrs_hunt_gloves example crafts a full item around this mechanic.

Three complete worked crafts ship inside the package — run them, or read their source for the full multi-stage patterns (fracture restarts, omen dances, reveal loops, blocker modifiers):

uv run --package optimalexile-crafting-sim python -m crafting_sim.examples.kurgal_solar_amulet
uv run --package optimalexile-crafting-sim python -m crafting_sim.examples.kolrs_hunt_gloves
uv run --package optimalexile-crafting-sim python -m crafting_sim.examples.thunder_dueling_wand

Each run prints a mean currency cost table and, below it, a share link that reopens the last craft in the browser simulator with its full history. Pass --site-url to point the link at a local dev server instead.

Gotchas

  • Pricing sources: the package ships no prices, so value_currency_usage requires an explicit rates argument. Pass rates=None when you have no price source: every entry is then reported in valuation.missing and total_divines is None, rather than valued against stale numbers. Get real rates from poe2_currency_rates_from_env(), which reads OPTIMALEXILE_PRICESlive to fetch current rates, or a path to a JSON file containing the complete live API response; unset returns None. Both configured sources require POE2_LEAGUE; file paths may be absolute or relative to the working directory. Loading errors propagate instead of silently falling back.
  • Omens are item state, set via set_active_omens/toggle_omen, not per-call arguments; they persist until cleared or consumed.
  • ImportError mentioning DATA_VERSION/API_VERSION means the generated enums and compiled core are out of sync — only relevant when building from the repo (just dev, just gen-enums).
  • Free edits (add_mod, set_rarity, socket edits) bypass crafting rules by design; use currency verbs when simulating legitimate crafts.

Everything below applies only to developing this package inside the optimalexile monorepo — not to using the installed library.

Development

skills/poe2-crafting-sim/ is an agent skill covering the same ground in progressive-disclosure form (mechanics, currencies, omens, socketables, recipes, measurement). Keep it in sync when the API changes.

just dev        # rebuild the extension into the uv venv after Rust changes
just test       # pytest
just lint       # ruff + mypy
just gen-enums  # regenerate crafting_sim/data/_generated.py after a data update

The extension crate lives at crates/crafting-sim-py and consumes the same crafting-sim-api catalog, import, replay, and query contract as the WebAssembly binding. Queries such as has_mod execute in Rust; a chaos-spam loop runs at roughly 10^5 applies/second single-threaded.

Releases

Linux wheels (manylinux x86_64 and aarch64, Python >= 3.13 via abi3) are published to PyPI (pip install optimalexile-crafting-sim) by .github/workflows/python-release.yml; no sdist is published. To cut a release: bump version in pyproject.toml and [workspace.package] version in the root Cargo.toml to the same value, then push a vX.Y.Z tag — CI fails if the three disagree.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_x86_64.whl (3.3 MB view details)

Uploaded CPython 3.13+manylinux: glibc 2.28+ x86-64

optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_aarch64.whl (3.3 MB view details)

Uploaded CPython 3.13+manylinux: glibc 2.28+ ARM64

File details

Details for the file optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 d50123ad622e88f3a7f8e100b9fc642061ad3fad9155e3b64ad142fa1aa2c408
MD5 d997242310661a04bc9efe0a0d05bbe2
BLAKE2b-256 6419a8452f9a7600523ce3334a89492bedf7ad5c52ac1cfaaf8f2e14f171c858

See more details on using hashes here.

Provenance

The following attestation bundles were made for optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_x86_64.whl:

Publisher: python-release.yml on optimalexile/optimalexile

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 5aeefba04a43969baead4542706348185c7b5836fb78b51430508ed2c1e665ad
MD5 2ed5cecd10bf6dde5c43510e196ae971
BLAKE2b-256 88a3fb73512bedd534b69c22800c3da4495bd754cb1f50722bf009b8f283e957

See more details on using hashes here.

Provenance

The following attestation bundles were made for optimalexile_crafting_sim-0.4.4-cp313-abi3-manylinux_2_28_aarch64.whl:

Publisher: python-release.yml on optimalexile/optimalexile

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page