From 7da27df0d53b15adfccfff3adaee9dbbd7215532 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Mon, 21 Sep 2026 23:29:26 -0700 Subject: [PATCH] Clear hotkeys on removal, unlock blueprints, show save info, add Changed only filter --- app.js | 123 ++++++++++++++++++++++++++++++++++++++++++++++++----- index.html | 4 ++ 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/app.js b/app.js index 030964c..8152d5d 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ 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 FIRST_PAGE_BLUEPRINT = 665; const $ = (id) => document.getElementById(id); const els = { @@ -18,6 +19,7 @@ search: $("search"), category: $("category"), ownedOnly: $("ownedOnly"), + changedOnly: $("changedOnly"), resultCount: $("resultCount"), rows: $("rows"), table: $("inventory"), @@ -43,6 +45,7 @@ query: "", category: "", ownedOnly: false, + changedOnly: false, source: null }; @@ -92,6 +95,7 @@ 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 (state.changedOnly && i.count === i.original) return false; if (!q) return true; return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum); }); @@ -209,7 +213,60 @@ return { outer, inner, innerIsString }; } - function applyInventory(inner) { + function isBlueprintPage(item) { + return item.category === "Blueprint" && item.id >= FIRST_PAGE_BLUEPRINT; + } + + async function readWrapped(zip, fileName, key) { + const entry = Object.values(zip.files).find((f) => !f.dir && f.name.split("/").pop() === fileName); + if (!entry) return null; + try { + const outer = JSON.parse(await entry.async("string")); + const raw = outer && outer.Data && outer.Data[key]; + if (raw === undefined) return null; + const innerIsString = typeof raw === "string"; + const inner = innerIsString ? JSON.parse(raw) : raw; + return { path: entry.name, key, outer, inner, innerIsString }; + } catch { + return null; + } + } + + function writeWrapped(zip, w) { + w.outer.Data[w.key] = w.innerIsString ? JSON.stringify(w.inner) : w.inner; + zip.file(w.path, JSON.stringify(w.outer)); + } + + function unlockedPages(playerState) { + const ids = new Set(); + const entries = playerState && Array.isArray(playerState.inner._entries) ? playerState.inner._entries : []; + for (const e of entries) { + const m = /^DiscoverablePageUnlocked_(\d+)$/.exec(e && e.Name); + if (m && e.BoolValue === true) ids.add(Number(m[1])); + } + return ids; + } + + function describeGame(gameState) { + if (!gameState) return ""; + const g = gameState.inner; + const parts = []; + if (Number.isFinite(g.GameDays)) { + const hh = String(g.GameHours || 0).padStart(2, "0"); + const mm = String(g.GameMinutes || 0).padStart(2, "0"); + parts.push(`Day ${g.GameDays} at ${hh}:${mm}`); + } + if (g.GameType) parts.push(`${g.GameType} mode`); + if (g.CrashSite) parts.push(`${g.CrashSite} crash site`); + let text = parts.join(", "); + if (g.SaveTime) { + const d = new Date(String(g.SaveTime).replace(/(\.\d{3})\d+/, "$1")); + if (!Number.isNaN(d.getTime())) text += `${text ? ". " : ""}Last saved ${d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}`; + } + return text ? `${text}.` : ""; + } + + function applyInventory(inner, pages) { buildCatalog(); const addUnknown = (id, count) => { const name = SOTF_NAMES[id] || `Unknown item ${id}`; @@ -231,6 +288,12 @@ item.equipped = true; } } + if (pages) { + for (const id of pages) { + const item = state.items.get(id); + if (item && isBlueprintPage(item)) item.count = Math.max(item.count, 1); + } + } for (const item of state.items.values()) item.original = item.count; ensureUnknownCategory(); } @@ -252,14 +315,19 @@ 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 }; + const hotkeys = zip ? await readWrapped(zip, "HotkeysSaveData.json", "Hotkeys") : null; + const playerState = zip ? await readWrapped(zip, "PlayerStateSaveData.json", "PlayerState") : null; + const gameState = zip ? await readWrapped(zip, "GameStateSaveData.json", "GameState") : null; + const pages = playerState && Array.isArray(playerState.inner._entries) ? unlockedPages(playerState) : null; + applyInventory(parsed.inner, pages); + state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", hotkeys, playerState: pages ? playerState : null, gameState, ...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.`; + const info = describeGame(gameState); + els.loaderStatus.textContent = `${info ? info + " " : ""}${owned} item types in your inventory. Drop another file to switch saves.`; render(); toast(`Loaded ${file.name}`); } catch (err) { @@ -268,12 +336,17 @@ } } - function buildInventoryText() { - const { outer, inner, innerIsString } = state.source; + function applyChanges() { + const src = state.source; + const { inner } = src; const blocks = inner.ItemInstanceManagerData.ItemBlocks; + const pagesInState = !!src.playerState; const removed = new Set(); + const pageChanges = []; for (const item of changedItems()) { if (item.equipped) continue; + const page = pagesInState && isBlueprintPage(item); + if (page) pageChanges.push(item); const matches = blocks.filter((b) => Number(b.ItemId) === item.id); if (item.count === 0) { removed.add(item.id); @@ -281,7 +354,7 @@ continue; } if (matches.length === 0) { - blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] }); + if (!page) blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] }); continue; } const [first, ...rest] = matches; @@ -295,8 +368,36 @@ 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); + src.outer.Data.PlayerInventory = src.innerIsString ? JSON.stringify(inner) : inner; + const touched = []; + if (src.hotkeys && removed.size) { + const h = src.hotkeys.inner; + for (const key of ["RightHandItemIds", "LeftHandItemIds"]) { + if (Array.isArray(h[key])) h[key] = h[key].map((id) => (removed.has(Number(id)) ? -1 : id)); + } + touched.push(src.hotkeys); + } + if (src.playerState && pageChanges.length) { + const entries = src.playerState.inner._entries; + const upsert = (name) => { + const e = entries.find((x) => x && x.Name === name); + if (e) e.BoolValue = true; + else entries.push({ Name: name, BoolValue: true }); + }; + for (const item of pageChanges) { + const unlockName = `DiscoverablePageUnlocked_${item.id}`; + if (item.count > 0) { + upsert(unlockName); + upsert(`hasOwned_${String(item.id).padStart(8, "0")}`); + } else { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i] && entries[i].Name === unlockName) entries.splice(i, 1); + } + } + } + touched.push(src.playerState); + } + return { inventoryText: JSON.stringify(src.outer), touched }; } function download(blob, name) { @@ -312,11 +413,12 @@ async function doSave() { try { - const text = buildInventoryText(); + const { inventoryText: text, touched } = applyChanges(); const src = state.source; let blob; if (src.kind === "zip") { src.zip.file(src.path, text); + for (const w of touched) writeWrapped(src.zip, w); blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); } else { blob = new Blob([text], { type: "application/json" }); @@ -409,6 +511,7 @@ }); els.category.addEventListener("change", () => { state.category = els.category.value; render(); }); els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; render(); }); + els.changedOnly.addEventListener("change", () => { state.changedOnly = els.changedOnly.checked; render(); }); els.table.querySelector("thead").addEventListener("click", (e) => { const th = e.target.closest("th[data-sort]"); diff --git a/index.html b/index.html index b30b021..bb3ca24 100644 --- a/index.html +++ b/index.html @@ -70,6 +70,10 @@ Owned only +