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; optional currency pricing can use 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, 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.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

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, 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 value_currency_usage

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

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

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
    target = pick(item.reveal_offer(slot))        # three candidate ModViews
    if target is None:
        target = pick(item.reroll_offer(slot))    # consumes the Abyssal Echoes
    if target is not None:
        item.reveal(target)                       # commit the candidate
        break
    # Miss: Omen of Light makes the next Annulment remove only Desecrated
    # modifiers (the unrevealed slot counts), so strip it and desecrate again.
    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.

Two 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):

python -m crafting_sim.examples.kurgal_solar_amulet
python -m crafting_sim.examples.kolrs_hunt_gloves

Gotchas

  • Pricing sources: value_currency_usage(usage) defaults to the embedded snapshot (DEFAULT_POE2_RATES, possibly stale). The example runner reads OPTIMALEXILE_PRICES: leave it unset for embedded rates, set it to live to fetch current rates, or set it to a JSON file containing the complete live API response. Both configured sources require POE2_LEAGUE; file paths may be absolute or relative to the working directory. Loading errors are reported without falling back to embedded prices.
  • 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.3.0-cp313-abi3-manylinux_2_28_x86_64.whl (3.1 MB view details)

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

optimalexile_crafting_sim-0.3.0-cp313-abi3-manylinux_2_28_aarch64.whl (3.1 MB view details)

Uploaded CPython 3.13+manylinux: glibc 2.28+ ARM64

File details

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

File metadata

File hashes

Hashes for optimalexile_crafting_sim-0.3.0-cp313-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 107d9347e29242ae372958398142790ec2a1463570488d863a81862bbab44ef7
MD5 7939e96b077761af713228f32735f8b4
BLAKE2b-256 f4e51fc3e9c84c2bf09f1b3495232aeccfdf6a7868c22abc8489185138684fe6

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for optimalexile_crafting_sim-0.3.0-cp313-abi3-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 f644fdf891af3b13708fbf5a9c3ce236ef4855c980cf0e3b6639b553643c71c9
MD5 30bee32c529f545446fa5de5f2006972
BLAKE2b-256 1d6fee599c4f7d48d382305ac4adfc5911cd9ecc2f32114cee67ee8926da6cc1

See more details on using hashes here.

Provenance

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