(() => { const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1"; const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves"; const INVENTORY_FILE = "PlayerInventorySaveData.json"; const KEEP_ON_CLEAR = new Set([351, 379, 380, 402, 412, 413, 483, 486, 552, 589]); const $ = (id) => document.getElementById(id); const els = { dropZone: $("dropZone"), loaderTitle: $("loaderTitle"), loaderStatus: $("loaderStatus"), browseBtn: $("browseBtn"), fileInput: $("fileInput"), copyPathBtn: $("copyPathBtn"), search: $("search"), category: $("category"), ownedOnly: $("ownedOnly"), resultCount: $("resultCount"), rows: $("rows"), table: $("inventory"), emptyState: $("emptyState"), changeSummary: $("changeSummary"), resetBtn: $("resetBtn"), clearBtn: $("clearBtn"), saveBtn: $("saveBtn"), disclaimer: $("disclaimer"), disclaimerOk: $("disclaimerOk"), saveDialog: $("saveDialog"), saveFileName: $("saveFileName"), saveCancel: $("saveCancel"), saveConfirm: $("saveConfirm"), toast: $("toast"), toolbar: document.querySelector(".toolbar") }; const state = { items: new Map(), sortKey: "count", sortDir: -1, query: "", category: "", ownedOnly: false, source: null }; function buildCatalog() { state.items.clear(); for (const [id, name, category, max] of SOTF_ITEMS) { state.items.set(id, { id, name, category, max, count: 0, original: 0, equipped: false, known: true }); } } function fillCategories() { const cats = [...new Set(SOTF_ITEMS.map((i) => i[2]))].sort(); for (const c of cats) { const opt = document.createElement("option"); opt.value = c; opt.textContent = c; els.category.appendChild(opt); } } function ensureUnknownCategory() { const hasUnknown = [...state.items.values()].some((i) => !i.known); const existing = els.category.querySelector('option[value="Unknown"]'); if (hasUnknown && !existing) { const opt = document.createElement("option"); opt.value = "Unknown"; opt.textContent = "Unknown"; els.category.appendChild(opt); } else if (!hasUnknown && existing) { existing.remove(); } } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function clamp(value, max) { const n = Math.floor(Number(value)); if (!Number.isFinite(n) || n < 0) return 0; return Math.min(n, max); } function visibleItems() { const q = state.query.trim().toLowerCase(); const qNum = /^\d+$/.test(q) ? Number(q) : null; const list = [...state.items.values()].filter((i) => { if (state.category && i.category !== state.category) return false; if (state.ownedOnly && i.count === 0 && !i.equipped) return false; if (!q) return true; return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum); }); const dir = state.sortDir; const key = state.sortKey; list.sort((a, b) => { let r; if (key === "name" || key === "category") r = a[key].localeCompare(b[key]); else r = a[key] - b[key]; if (r === 0) r = key === "name" ? a.id - b.id : a.name.localeCompare(b.name); else r *= dir; return r; }); return list; } function rowHtml(i) { const locked = i.equipped; const pills = []; if (i.equipped) pills.push('equipped'); if (!i.known) pills.push('unknown'); return ` ${escapeHtml(i.name)}${pills.join("")} ${escapeHtml(i.category)} ${i.id}
${i.max.toLocaleString()} `; } function rowClass(i) { const c = []; if (i.count !== i.original) c.push("changed"); if (i.count === 0 && !i.equipped) c.push("zero"); return c.join(" "); } function render() { const list = visibleItems(); els.rows.innerHTML = list.map(rowHtml).join(""); els.emptyState.hidden = list.length > 0; els.resultCount.textContent = `${list.length} of ${state.items.size} items`; for (const th of els.table.querySelectorAll("th[data-sort]")) { if (th.dataset.sort === state.sortKey) th.setAttribute("aria-sort", state.sortDir === 1 ? "ascending" : "descending"); else th.removeAttribute("aria-sort"); } updateSummary(); } function updateRow(i) { const tr = els.rows.querySelector(`tr[data-id="${i.id}"]`); if (!tr) return; tr.className = rowClass(i); const input = tr.querySelector("input"); if (document.activeElement !== input || Number(input.value) !== i.count) input.value = i.count; tr.querySelector('[data-step="-1"]').disabled = i.equipped || i.count <= 0; tr.querySelector('[data-step="1"]').disabled = i.equipped || i.count >= i.max; tr.querySelector("[data-fill]").disabled = i.equipped || i.count >= i.max; } function changedItems() { return [...state.items.values()].filter((i) => i.count !== i.original); } function clearableItems() { return [...state.items.values()].filter((i) => !i.equipped && !KEEP_ON_CLEAR.has(i.id)); } function updateSummary() { const n = changedItems().length; els.changeSummary.textContent = n === 0 ? "No changes" : `${n} item${n === 1 ? "" : "s"} changed`; els.changeSummary.classList.toggle("has-changes", n > 0); els.resetBtn.disabled = n === 0; els.clearBtn.disabled = !clearableItems().some((i) => i.count > 0); els.saveBtn.disabled = !state.source || n === 0; els.saveBtn.title = state.source ? "" : "Load a save first"; } function setCount(id, value) { const i = state.items.get(id); if (!i || i.equipped) return; i.count = clamp(value, i.max); updateRow(i); updateSummary(); } let toastTimer; function toast(msg, isError) { els.toast.textContent = msg; els.toast.classList.toggle("error", !!isError); els.toast.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => els.toast.classList.remove("show"), isError ? 7000 : 4500); } function parseInventory(text) { let outer; try { outer = JSON.parse(text); } catch { throw new Error(`${INVENTORY_FILE} isn't valid JSON. The save may be damaged.`); } const raw = outer && outer.Data && outer.Data.PlayerInventory; if (raw === undefined) throw new Error(`${INVENTORY_FILE} has no PlayerInventory data. This doesn't look like a Sons of the Forest save.`); const innerIsString = typeof raw === "string"; const inner = innerIsString ? JSON.parse(raw) : raw; const blocks = inner && inner.ItemInstanceManagerData && inner.ItemInstanceManagerData.ItemBlocks; if (!Array.isArray(blocks)) throw new Error("The inventory has no item list. This save format isn't supported."); return { outer, inner, innerIsString }; } function applyInventory(inner) { buildCatalog(); const addUnknown = (id, count) => { const name = SOTF_NAMES[id] || `Unknown item ${id}`; const item = { id, name, category: "Unknown", max: Math.max(count, 1), count: 0, original: 0, equipped: false, known: false }; state.items.set(id, item); return item; }; for (const block of inner.ItemInstanceManagerData.ItemBlocks) { const id = Number(block.ItemId); const count = Math.max(0, Number(block.TotalCount) || 0); const item = state.items.get(id) || addUnknown(id, count); item.count += count; if (item.count > item.max) item.max = item.count; } if (Array.isArray(inner.EquippedItems)) { for (const block of inner.EquippedItems) { const id = Number(block.ItemId); const item = state.items.get(id) || addUnknown(id, 0); item.equipped = true; } } for (const item of state.items.values()) item.original = item.count; ensureUnknownCategory(); } async function loadFile(file) { if (!file) return; try { const isJson = /\.json$/i.test(file.name); let zip = null; let path = INVENTORY_FILE; let text; if (isJson) { text = await file.text(); } else { zip = await JSZip.loadAsync(file); const entry = Object.values(zip.files).find((f) => !f.dir && f.name.split("/").pop() === INVENTORY_FILE); if (!entry) throw new Error(`${file.name} has no ${INVENTORY_FILE}. Pick the SaveData.zip inside one of your save folders.`); path = entry.name; text = await entry.async("string"); } const parsed = parseInventory(text); applyInventory(parsed.inner); state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", ...parsed }; state.sortKey = "count"; state.sortDir = -1; const owned = [...state.items.values()].filter((i) => i.count > 0).length; els.dropZone.classList.add("loaded"); els.loaderTitle.textContent = `Loaded ${file.name}`; els.loaderStatus.textContent = `${owned} item types in your inventory. Drop another file to switch saves.`; render(); toast(`Loaded ${file.name}`); } catch (err) { console.error(err); toast(err && err.message ? err.message : `Couldn't read ${file.name}.`, true); } } function buildInventoryText() { const { outer, inner, innerIsString } = state.source; const blocks = inner.ItemInstanceManagerData.ItemBlocks; const removed = new Set(); for (const item of changedItems()) { if (item.equipped) continue; const matches = blocks.filter((b) => Number(b.ItemId) === item.id); if (item.count === 0) { removed.add(item.id); for (const b of matches) blocks.splice(blocks.indexOf(b), 1); continue; } if (matches.length === 0) { blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] }); continue; } const [first, ...rest] = matches; first.TotalCount = item.count; if (Array.isArray(first.UniqueItems) && first.UniqueItems.length > item.count) { first.UniqueItems = first.UniqueItems.slice(0, item.count); } for (const b of rest) blocks.splice(blocks.indexOf(b), 1); } const slots = inner.QuickSelect && inner.QuickSelect.Slots; if (Array.isArray(slots) && removed.size) { inner.QuickSelect.Slots = slots.filter((s) => !(s && removed.has(Number(s.ItemId)))); } outer.Data.PlayerInventory = innerIsString ? JSON.stringify(inner) : inner; return JSON.stringify(outer); } function download(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); } async function doSave() { try { const text = buildInventoryText(); const src = state.source; let blob; if (src.kind === "zip") { src.zip.file(src.path, text); blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); } else { blob = new Blob([text], { type: "application/json" }); } download(blob, src.fileName); for (const item of state.items.values()) item.original = item.count; render(); toast(`Downloaded ${src.fileName}. Replace the file in your save folder, then start the game.`); } catch (err) { console.error(err); toast(`Couldn't build the new save: ${err.message}`, true); } } async function browse() { if (window.showOpenFilePicker) { try { const [handle] = await window.showOpenFilePicker({ id: "sotf-saves", startIn: "documents", types: [{ description: "Sons of the Forest save", accept: { "application/zip": [".zip"], "application/json": [".json"] } }] }); loadFile(await handle.getFile()); return; } catch (err) { if (err && err.name === "AbortError") return; } } els.fileInput.value = ""; els.fileInput.click(); } function setToolbarOffset() { document.documentElement.style.setProperty("--toolbar-h", `${els.toolbar.offsetHeight}px`); } function bind() { els.copyPathBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(SAVES_PATH); toast("Saves folder path copied. Paste it into the file dialog's address bar, then open your SteamID and save folders."); } catch { toast("Couldn't copy. Select the path and copy it manually.", true); } }); els.browseBtn.addEventListener("click", browse); els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0])); let dragDepth = 0; const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes("Files"); window.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); }); window.addEventListener("drop", (e) => { if (hasFiles(e)) e.preventDefault(); }); els.dropZone.addEventListener("dragenter", (e) => { if (!hasFiles(e)) return; dragDepth++; els.dropZone.classList.add("dragging"); }); els.dropZone.addEventListener("dragleave", () => { dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) els.dropZone.classList.remove("dragging"); }); els.dropZone.addEventListener("drop", (e) => { e.preventDefault(); dragDepth = 0; els.dropZone.classList.remove("dragging"); loadFile(e.dataTransfer.files[0]); }); els.dropZone.addEventListener("keydown", (e) => { if (e.target === els.dropZone && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); browse(); } }); let searchTimer; els.search.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { state.query = els.search.value; render(); }, 80); }); els.category.addEventListener("change", () => { state.category = els.category.value; render(); }); els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; render(); }); els.table.querySelector("thead").addEventListener("click", (e) => { const th = e.target.closest("th[data-sort]"); if (!th) return; const key = th.dataset.sort; if (state.sortKey === key) state.sortDir *= -1; else { state.sortKey = key; state.sortDir = key === "name" || key === "category" ? 1 : -1; } render(); }); els.rows.addEventListener("click", (e) => { const tr = e.target.closest("tr[data-id]"); if (!tr) return; const id = Number(tr.dataset.id); const item = state.items.get(id); const step = e.target.closest("[data-step]"); if (step) { const amount = Number(step.dataset.step) * (e.shiftKey ? 10 : 1); setCount(id, item.count + amount); } else if (e.target.closest("[data-fill]")) { setCount(id, item.max); } }); els.rows.addEventListener("input", (e) => { if (e.target.tagName !== "INPUT") return; const id = Number(e.target.closest("tr").dataset.id); const item = state.items.get(id); if (e.target.value === "") return; const v = clamp(e.target.value, item.max); if (String(v) !== e.target.value) e.target.value = v; setCount(id, v); }); els.rows.addEventListener("focusout", (e) => { if (e.target.tagName !== "INPUT") return; const id = Number(e.target.closest("tr").dataset.id); const item = state.items.get(id); if (item) e.target.value = item.count; }); els.rows.addEventListener("keydown", (e) => { if (e.target.tagName === "INPUT" && e.key === "Enter") e.target.blur(); }); els.resetBtn.addEventListener("click", () => { for (const item of state.items.values()) item.count = item.original; render(); }); els.clearBtn.addEventListener("click", () => { for (const item of clearableItems()) item.count = 0; render(); toast("Inventory cleared. Equipped items and starting tools were kept. Use Undo changes to restore."); }); els.saveBtn.addEventListener("click", () => { if (!state.source) return; els.saveFileName.textContent = state.source.fileName; els.saveDialog.showModal(); }); els.saveCancel.addEventListener("click", () => els.saveDialog.close()); els.saveConfirm.addEventListener("click", () => { els.saveDialog.close(); doSave(); }); els.disclaimerOk.addEventListener("click", () => { try { localStorage.setItem(DISCLAIMER_KEY, "1"); } catch {} els.disclaimer.close(); }); els.disclaimer.addEventListener("cancel", (e) => e.preventDefault()); window.addEventListener("beforeunload", (e) => { if (changedItems().length) { e.preventDefault(); e.returnValue = ""; } }); window.addEventListener("resize", setToolbarOffset); } function showDisclaimerIfNeeded() { let seen = false; try { seen = localStorage.getItem(DISCLAIMER_KEY) === "1"; } catch {} if (!seen) els.disclaimer.showModal(); } buildCatalog(); fillCategories(); bind(); render(); setToolbarOffset(); showDisclaimerIfNeeded(); })();