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.
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
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
Socketables — socket() 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: the package ships no prices, so
value_currency_usagerequires an explicitratesargument. Passrates=Nonewhen you have no price source: every entry is then reported invaluation.missingandtotal_divinesisNone, rather than valued against stale numbers. Get real rates frompoe2_currency_rates_from_env(), which readsOPTIMALEXILE_PRICES—liveto fetch current rates, or a path to a JSON file containing the complete live API response; unset returnsNone. Both configured sources requirePOE2_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. ImportErrormentioning 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
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_x86_64.whl.
File metadata
- Download URL: optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_x86_64.whl
- Upload date:
- Size: 3.2 MB
- Tags: CPython 3.13+, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ed75576b19f48a7181cd51e2988cf1af5c36427d802138d1d0a91c88b5348563
|
|
| MD5 |
66f243ae59ed0f6399a3920544fa512c
|
|
| BLAKE2b-256 |
9607a791e5520bc7ca135515a50857f455b757f7452a75d9747bd762166136b4
|
Provenance
The following attestation bundles were made for optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_x86_64.whl:
Publisher:
python-release.yml on optimalexile/optimalexile
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_x86_64.whl -
Subject digest:
ed75576b19f48a7181cd51e2988cf1af5c36427d802138d1d0a91c88b5348563 - Sigstore transparency entry: 2204757694
- Sigstore integration time:
-
Permalink:
optimalexile/optimalexile@06376e03b37d6df8ec2c39ce5bd0e578a0de533e -
Branch / Tag:
refs/tags/v0.3.1 - Owner: https://github.com/optimalexile
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-release.yml@06376e03b37d6df8ec2c39ce5bd0e578a0de533e -
Trigger Event:
push
-
Statement type:
File details
Details for the file optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_aarch64.whl.
File metadata
- Download URL: optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_aarch64.whl
- Upload date:
- Size: 3.1 MB
- Tags: CPython 3.13+, manylinux: glibc 2.28+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0de7bef70742b26ddf68de2bb12f8c22e4dcaead57ff3231c5dc8d34ccd96ac3
|
|
| MD5 |
2e49d4fe107b2270f578733956f51052
|
|
| BLAKE2b-256 |
f58ff8c4811e1a2d9fe983509c49f8979d8c0ddcbe1c60e50c23b1e8d54b604e
|
Provenance
The following attestation bundles were made for optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_aarch64.whl:
Publisher:
python-release.yml on optimalexile/optimalexile
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
optimalexile_crafting_sim-0.3.1-cp313-abi3-manylinux_2_28_aarch64.whl -
Subject digest:
0de7bef70742b26ddf68de2bb12f8c22e4dcaead57ff3231c5dc8d34ccd96ac3 - Sigstore transparency entry: 2204757700
- Sigstore integration time:
-
Permalink:
optimalexile/optimalexile@06376e03b37d6df8ec2c39ce5bd0e578a0de533e -
Branch / Tag:
refs/tags/v0.3.1 - Owner: https://github.com/optimalexile
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-release.yml@06376e03b37d6df8ec2c39ce5bd0e578a0de533e -
Trigger Event:
push
-
Statement type: