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.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 steer 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()

The shipped kolrs_hunt_gloves example makes the same decision prefix-side.

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

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 — the socketable's catalog description does not mention the tag, so check the mod ids' prefix. 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

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.1-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.1-cp313-abi3-manylinux_2_28_aarch64.whl (3.2 MB view details)

Uploaded CPython 3.13+manylinux: glibc 2.28+ ARM64

File details

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

File metadata

File hashes

Hashes for optimalexile_crafting_sim-0.4.1-cp313-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 78b729c20b9f60df789c88eb30dc0b13176c8c32734dcf32bdcdd8d2906ea38a
MD5 a40686bdb47c2cf8ad6f5126e5061b8c
BLAKE2b-256 ff1b43f5197541048e79b008c990d668e7e75298e14df115d0abc63d48af0982

See more details on using hashes here.

Provenance

The following attestation bundles were made for optimalexile_crafting_sim-0.4.1-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.1-cp313-abi3-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for optimalexile_crafting_sim-0.4.1-cp313-abi3-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 b7838c11871861bd04ca07f8ded95c00329f2415c69dab769ea03f1596a2a5af
MD5 e30313248eb2b7dbbb969aed653dd4be
BLAKE2b-256 2365c9e69be0d29ceb6dfb781f21505bf66054a19b35e2ab5511224961d4bce2

See more details on using hashes here.

Provenance

The following attestation bundles were made for optimalexile_crafting_sim-0.4.1-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