Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

# Ignore the wiki dump data
wiki-dump/

# NEU item cache used only by tools/build-craft-recipes.py
tools/.neu-cache/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 2026-09-02

- Added **craft prices to the Accessory Path**: every card now shows, next to its Auction-House / bazaar price, what the accessory costs to craft yourself and whether it can be crafted at all. Three chip states — `craft 194.47k` (green when crafting beats buying, with the saving as `−88%`), `craft ≥ 8.07M` (recipe exists but some material is unbuyable, listed on expand), and `no recipe`. Clicking a chip expands the full material list with per-material prices and their source (bazaar / AH / owned / crafted).
- Materials are priced from live markets, cheapest source first: an accessory you already own inside an upgrade chain is free (Wolf Talisman inside a Wolf Ring), then bazaar (insta-buy / buy-order, following the global toggle), then Auction-House lowest BIN, then the material's own recipe recursively.
- Added a **Craft prices** toggle (on by default, remembered per browser) and a **Craft cost (cheapest first)** sort option to the Accessory Path toolbar; the summary row gained a "craftable" counter and the page shows the total you'd save by crafting every step where it is cheaper.
- The cost-per-MP ratio on each path step now uses the cheaper of buy vs craft and is tagged `craft` when crafting wins.
- Added `craft-cost.js` (the recursive pricing engine) and `data/accessory-recipes.json` (~40 KB, recipes for all accessories plus their material closure). Hypixel's items endpoint has no recipe data, so the file is generated by the new `tools/build-craft-recipes.py` from the community NotEnoughUpdates repo — re-run it after major game updates.
- Fixed the Accessory Path sort comparators returning `NaN` (and thus an unstable order) when two unpriced accessories were compared.

## 2026-06-29

- Added a **Calendar** page: a live SkyBlock clock (date, in-game time, and day-progress bar derived from the Year 1 epoch), an in-game-style month grid with prev/next navigation and a "today" highlight, and an Upcoming Events panel with real-time countdowns. Tracks the Dark Auction and Jacob's Farming Contest (every 3 SkyBlock days), the Traveling Zoo, Spooky Festival, Season of Jerry, New Year Celebration, and the mayor election / new-mayor dates. Inspired by inventivetalent's SkyBlock calendar.
Expand Down
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,16 @@ shard-market/
├── nbt.js ← Minimal NBT parser (decodes the gzipped inventory blob)
├── prices.js ← Unified price resolver: bazaar + AH lowest-BIN scan (used by accessories, Sweep, P2W)
├── accessories.js ← Accessory catalog, upgrade families, Magical Power math
├── craft-cost.js ← Recursive crafting-cost engine (bazaar/AH materials → craft price)
├── attributes.js ← Attribute catalog + shards-to-max calculation
├── CHANGELOG.md ← Release notes and contributor credits
├── tools/
│ └── build-craft-recipes.py ← One-off generator for data/accessory-recipes.json
├── data/
│ ├── fusion-properties.json ← Per-shard metadata (189 shards, from SkyShards)
│ ├── fusion-data.json ← Full fusion recipe graph (~2 MB, from SkyShards)
│ └── attribute-desc.json ← Attribute id → rarity/title/effect (from SkyShards)
│ ├── attribute-desc.json ← Attribute id → rarity/title/effect (from SkyShards)
│ └── accessory-recipes.json ← Crafting recipes for accessories + their material closure
└── README.md
```

Expand Down Expand Up @@ -92,6 +96,53 @@ Fish Bowls, Kuudra organs, Crux Chronomicon → Celestial Starstone, Helianthus
and Shady Ring → Seal of the Family are treated as one upgrade path instead of separate
missing accessories.

**Craft prices.** Next to every Auction-House / bazaar price the Accessory Path also shows
what the item costs to **craft yourself**, and whether that is possible at all. Flip the
**Craft prices** chip (on by default) and pick **Craft cost (cheapest first)** in the sort
menu to rank the whole path by crafting cost. Click any craft chip to expand the full
material list with per-material prices and where each one comes from.

### How craft cost is computed

Hypixel's `/v2/resources/skyblock/items` endpoint has no recipe data, so the recipes are
bundled once at development time in `data/accessory-recipes.json` (≈40 KB) by
`tools/build-craft-recipes.py`, which reads the community
[NotEnoughUpdates repo](https://github.com/NotEnoughUpdates/NotEnoughUpdates-REPO):

```bash
python tools/build-craft-recipes.py # incremental — caches into tools/.neu-cache/
python tools/build-craft-recipes.py --refresh # re-download the recipe files
```

It starts from every `ACCESSORY` item, then walks the ingredient closure: any material that
is neither on the bazaar nor already fetched is fetched too, so deep chains
(`Bat Talisman → Bat Ring → Bat Artifact`, `Enchanted X → Enchanted X Block`) can be priced.
Re-run it after big game updates.

At runtime `craft-cost.js` walks that graph and prices every leaf material live:

| Priority | Source | Notes |
| --- | --- | --- |
| 1 | **Already owned** | the base accessory in an upgrade chain (e.g. your Wolf Talisman inside a Wolf Ring) is free |
| 2 | **Bazaar** | insta-buy or buy-order, following the global bazaar-mode toggle |
| 3 | **Auction House** | lowest BIN, for materials that never list on the bazaar |
| 4 | **Craft it** | the material's own recipe, recursively |

Each chip reports one of three states:

- `craft 194.47k` — fully craftable; green when it beats the buy price, with the saving
shown as `−88%`. The tag reads **bazaar mats** when every material comes off the bazaar,
or **bz + AH mat** when at least one has to be bought on the Auction House.
- `craft ≥ 8.07M` — has a recipe but not fully craftable: that much of it is buyable, the
rest (quest drops, dungeon drops, soulbound gear) must be obtained another way, and the
expanded panel lists exactly what.
- `no recipe` — nothing to craft; it has to be bought, dropped or earned.

What is **not** modelled: NPC shop prices, forge fuel/catalyst costs beyond the listed
inputs, collection and skill requirements (a recipe may exist while you still lack the
Heart of the Mountain tier), and craft-time opportunity cost. Forge recipes show their
duration (`1.5d in the forge`) so you can judge that yourself.

### Attributes
How many **Attribute Shards** you still need to take each attribute to level 10.
The report includes the full 189-entry SkyShards catalog, marks attributes you have never
Expand Down
256 changes: 256 additions & 0 deletions craft-cost.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/* =========================================================================
* craft-cost.js
*
* "What would it cost to craft this myself?" for accessories.
*
* Hypixel's own items endpoint has no recipe data, so recipes are bundled
* statically in data/accessory-recipes.json (generated once from the
* NotEnoughUpdates community repo by tools/build-craft-recipes.py).
*
* The engine walks a recipe tree recursively and prices every leaf material
* from the live market:
*
* 1. material already owned by the player -> 0 (upgrade chains)
* 2. bazaar insta-buy / buy-order (per mode) -> bazaar price
* 3. auction house lowest BIN -> BIN
* 4. the material is itself craftable -> its own craft cost
*
* Every resolved material is the cheapest of the options above; materials that
* resolve to nothing (quest drops, dungeon drops, soulbound gear) are reported
* as `missing`, which is what makes an item "not craftable".
*
* Exposes on window:
* loadCraftRecipes() -> Promise<{recipes, names, loaded, error}>
* createCraftPricer(opts) -> { analyse(id), bestUnit(id), craftOf(id) }
* createCraftPricer().analyse(id) -> {
* hasRecipe, craftable, cost, partialCost, pureBazaar,
* type, duration, out, materials[], missing[], freeInputs[]
* }
* ======================================================================= */

"use strict";

const CRAFT_RECIPES_URL = "data/accessory-recipes.json?v=20260902-recipes";

/* Fallback display names when the Hypixel items API has no record for an
* ingredient id (NEU internal ids that Hypixel never exposes). */
function prettyItemId(id) {
return String(id || "")
.toLowerCase()
.split("_")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}

let craftRecipesPromise = null;

/* Fetch the bundled recipe graph once per page load. Never throws — the site
* must keep working (without craft prices) if the file is missing. */
function loadCraftRecipes() {
if (craftRecipesPromise) return craftRecipesPromise;
craftRecipesPromise = (async () => {
try {
const res = await fetch(CRAFT_RECIPES_URL);
if (!res.ok) throw new Error(`recipe data HTTP ${res.status}`);
const json = await res.json();
return {
recipes: json.recipes || {},
names: json.names || {},
loaded: true,
error: null,
};
} catch (e) {
console.warn("[Hypixie] craft recipe data unavailable:", e);
return { recipes: {}, names: {}, loaded: false, error: String(e?.message || e) };
}
})();
return craftRecipesPromise;
}

/* Build a pricer bound to one snapshot of the market.
*
* bazaar — live bazaar products map (state.raw.products)
* bins — Map<id, price> of AH lowest BINs
* bazaarMode — "instaBuy" | "buyOrder"
* owned — Set/Map of accessory ids the player already owns (cost 0)
* recipes — the bundled recipe graph
* names — NEU display names, used only as a fallback
* nameOf — optional (id) => string, normally the Hypixel items API name
*/
function createCraftPricer(opts = {}) {
const {
bazaar = null,
bins = null,
bazaarMode = "instaBuy",
owned = null,
recipes = {},
names = {},
nameOf = null,
maxDepth = 12,
} = opts;

const ownedIds =
owned instanceof Map ? new Set(owned.keys()) : owned instanceof Set ? new Set(owned) : new Set();

const unitMemo = new Map();
const craftMemo = new Map();
const inflight = new Set();

const label = (id) => {
if (typeof nameOf === "function") {
const n = nameOf(id);
if (n) return n;
}
return names[id] || prettyItemId(id);
};

/* Cheapest market price for one unit of `id`, or null. */
function marketUnit(id) {
const prod = bazaar?.[id];
if (prod?.quick_status) {
const instaBuy = prod.quick_status.buyPrice || null;
const buyOrder = prod.quick_status.sellPrice || null;
const price =
bazaarMode === "buyOrder" ? (buyOrder ?? instaBuy) : (instaBuy ?? buyOrder);
if (price != null && price > 0) {
return { price, source: "bazaar", sourceLabel: bazaarMode === "buyOrder" ? "buy order" : "insta-buy" };
}
}
const bin = bins?.get(id);
if (bin != null && bin > 0) return { price: bin, source: "ah", sourceLabel: "lowest BIN" };
return null;
}

/* Cheapest way to buy or craft 1 unit of `id`. Owned items are handled by
* the caller (craftOf) because only it knows how many are required. */
function bestUnit(id, depth = 0) {
if (unitMemo.has(id)) return unitMemo.get(id);
if (inflight.has(id) || depth > maxDepth) return null; // cycle / runaway guard
inflight.add(id);

let best = null;
try {
const mkt = marketUnit(id);
if (mkt && (!best || mkt.price < best.price)) best = mkt;

const craft = craftOf(id, depth + 1);
if (craft && craft.craftable && Number.isFinite(craft.cost)) {
const cand = {
price: craft.cost,
source: craft.pureBazaar ? "craft" : "craft-ah",
sourceLabel: craft.pureBazaar ? "crafted" : "crafted (needs AH part)",
};
if (!best || cand.price < best.price) best = cand;
}
} finally {
inflight.delete(id);
}

unitMemo.set(id, best);
return best;
}

/* Raw craft cost of `id` — the cheapest of its known recipes. */
function craftOf(id, depth = 0) {
if (craftMemo.has(id)) return craftMemo.get(id);
const list = recipes[id];
if (!list || !list.length || depth > maxDepth) {
const none = { hasRecipe: false, craftable: false, cost: null, partialCost: null, materials: [], missing: [], freeInputs: [] };
craftMemo.set(id, none);
return none;
}
if (inflight.has(id)) return null;
inflight.add(id);

let best = null;
try {
for (const r of list) {
const materials = [];
const missing = [];
const freeInputs = [];
let total = 0;
let ok = true;
let usedAh = false;

for (const [ingId, amount] of Object.entries(r.in || {})) {
/* A player-owned accessory covers the recipe's first copy for free —
* that is the whole point of an upgrade chain (Wolf Talisman → Wolf
* Ring). Any further copies (Scarf's Thesis needs 4 Studies) still
* have to be sourced. */
const ownedUnits = ownedIds.has(ingId) ? Math.min(amount, 1) : 0;
const toBuy = amount - ownedUnits;

let subtotal = 0;
let source = ownedUnits ? "owned" : null;
let sourceLabel = ownedUnits ? "you own it" : null;

if (toBuy > 0) {
const unit = bestUnit(ingId, depth + 1);
if (!unit) {
ok = false;
missing.push({ id: ingId, name: label(ingId), amount: toBuy });
continue;
}
subtotal = unit.price * toBuy;
if (unit.source === "ah" || unit.source === "craft-ah") usedAh = true;
if (!source) { source = unit.source; sourceLabel = unit.sourceLabel; }
else if (unit.source !== "bazaar") { source = unit.source; sourceLabel = unit.sourceLabel; }
}

total += subtotal;
if (ownedUnits) freeInputs.push({ id: ingId, name: label(ingId), amount: ownedUnits });
materials.push({
id: ingId,
name: label(ingId),
amount,
ownedUnits,
unit: toBuy ? subtotal / toBuy : 0,
subtotal,
source: source || "owned",
sourceLabel: sourceLabel || "you own it",
});
}

const out = r.out || 1;
const cost = ok ? total / out : null;
const cand = {
hasRecipe: true,
craftable: ok,
cost,
partialCost: ok ? cost : total / out,
pureBazaar: ok && !usedAh,
type: r.type || "crafting",
duration: r.duration ?? null,
out,
materials: materials.sort((a, b) => b.subtotal - a.subtotal),
missing,
freeInputs,
};

if (!best) best = cand;
else if (cand.craftable && (!best.craftable || cand.cost < best.cost)) best = cand;
else if (!cand.craftable && !best.craftable && (cand.missing.length < best.missing.length)) best = cand;
}
} finally {
inflight.delete(id);
}

if (best) craftMemo.set(id, best);
return best;
}

/* Display-ready analysis of one accessory. */
function analyse(id) {
const c = craftOf(String(id || "").toUpperCase(), 0);
if (!c) return null;
if (!c.hasRecipe) return { hasRecipe: false, craftable: false, cost: null, materials: [], missing: [], freeInputs: [] };
return c;
}

return { analyse, bestUnit, craftOf, label };
}

window.loadCraftRecipes = loadCraftRecipes;
window.createCraftPricer = createCraftPricer;
window.prettyItemId = prettyItemId;
1 change: 1 addition & 0 deletions data/accessory-recipes.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ <h4><img class="inline-mc-icon" src="https://sky.shiiyu.moe/api/item/LEAD" alt="
<script src="nbt.js?v=20260603"></script>
<script src="prices.js?v=20260708-firesale-cosmetics"></script>
<script src="accessories.js?v=20260709-bingo-soulbound"></script>
<script src="craft-cost.js?v=20260902-craft-prices"></script>
<script src="attributes.js?v=20260603-attr-cheapest-max"></script>
<script src="sweep-data.js?v=20260603"></script>
<script src="minions-data.js?v=20260604-minion-aliases"></script>
Expand Down
Loading