Metadata-Version: 2.4
Name: optimalexile-crafting-sim
Version: 0.4.5
Requires-Dist: rich>=14
Summary: Python bindings for the PoE2 crafting simulator
Author: optimalexile
Requires-Python: >=3.13
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

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

## Documentation

```
python -m crafting_sim help
```

prints the bundled `poe2-crafting-sim` agent skill — mechanics, currencies,
omens, socketables, recipes and measurement, in full. It ships inside the wheel,
so it works with no network access; `crafting_sim.skill_text()` returns the same
text in-process.

## Discover, don't memorize

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

```python
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:

```python
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

```python
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:

```python
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 `StrEnum`s 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:

```python
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:

```python
# 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 browser** — `crafting_sim.share` compresses a replay
into the share code the web simulator reads from the URL fragment:

```python
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:

```python
# 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`:

```python
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:

```python
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:

```python
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:

```python
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 type** — `Omen.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:

```python
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.

**Socketables** — `socket()` consumes the currency; the `set_socketable`/
`add_socket` edits reshape sockets for free when modeling an item instead:

```python
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:

```python
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_PRICES`
  — `live` 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

`src/crafting_sim/skills/poe2-crafting-sim/` is an agent skill covering the same
ground in progressive-disclosure form (mechanics, currencies, omens,
socketables, recipes, measurement). It ships as package data and is what
`python -m crafting_sim help` prints, so 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.

