Clear hotkeys on removal, unlock blueprints, show save info, add Changed only filter

This commit is contained in:
Justin Oros
2026-09-21 23:29:26 -07:00
parent ab00fb685a
commit 7da27df0d5
2 changed files with 117 additions and 10 deletions
+113 -10
View File
@@ -3,6 +3,7 @@
const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves"; const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves";
const INVENTORY_FILE = "PlayerInventorySaveData.json"; const INVENTORY_FILE = "PlayerInventorySaveData.json";
const KEEP_ON_CLEAR = new Set([351, 379, 380, 402, 412, 413, 483, 486, 552, 589]); 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 $ = (id) => document.getElementById(id);
const els = { const els = {
@@ -18,6 +19,7 @@
search: $("search"), search: $("search"),
category: $("category"), category: $("category"),
ownedOnly: $("ownedOnly"), ownedOnly: $("ownedOnly"),
changedOnly: $("changedOnly"),
resultCount: $("resultCount"), resultCount: $("resultCount"),
rows: $("rows"), rows: $("rows"),
table: $("inventory"), table: $("inventory"),
@@ -43,6 +45,7 @@
query: "", query: "",
category: "", category: "",
ownedOnly: false, ownedOnly: false,
changedOnly: false,
source: null source: null
}; };
@@ -92,6 +95,7 @@
const list = [...state.items.values()].filter((i) => { const list = [...state.items.values()].filter((i) => {
if (state.category && i.category !== state.category) return false; if (state.category && i.category !== state.category) return false;
if (state.ownedOnly && i.count === 0 && !i.equipped) 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; if (!q) return true;
return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum); return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum);
}); });
@@ -209,7 +213,60 @@
return { outer, inner, innerIsString }; 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(); buildCatalog();
const addUnknown = (id, count) => { const addUnknown = (id, count) => {
const name = SOTF_NAMES[id] || `Unknown item ${id}`; const name = SOTF_NAMES[id] || `Unknown item ${id}`;
@@ -231,6 +288,12 @@
item.equipped = true; 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; for (const item of state.items.values()) item.original = item.count;
ensureUnknownCategory(); ensureUnknownCategory();
} }
@@ -252,14 +315,19 @@
text = await entry.async("string"); text = await entry.async("string");
} }
const parsed = parseInventory(text); const parsed = parseInventory(text);
applyInventory(parsed.inner); const hotkeys = zip ? await readWrapped(zip, "HotkeysSaveData.json", "Hotkeys") : null;
state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", ...parsed }; 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.sortKey = "count";
state.sortDir = -1; state.sortDir = -1;
const owned = [...state.items.values()].filter((i) => i.count > 0).length; const owned = [...state.items.values()].filter((i) => i.count > 0).length;
els.dropZone.classList.add("loaded"); els.dropZone.classList.add("loaded");
els.loaderTitle.textContent = `Loaded ${file.name}`; 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(); render();
toast(`Loaded ${file.name}`); toast(`Loaded ${file.name}`);
} catch (err) { } catch (err) {
@@ -268,12 +336,17 @@
} }
} }
function buildInventoryText() { function applyChanges() {
const { outer, inner, innerIsString } = state.source; const src = state.source;
const { inner } = src;
const blocks = inner.ItemInstanceManagerData.ItemBlocks; const blocks = inner.ItemInstanceManagerData.ItemBlocks;
const pagesInState = !!src.playerState;
const removed = new Set(); const removed = new Set();
const pageChanges = [];
for (const item of changedItems()) { for (const item of changedItems()) {
if (item.equipped) continue; if (item.equipped) continue;
const page = pagesInState && isBlueprintPage(item);
if (page) pageChanges.push(item);
const matches = blocks.filter((b) => Number(b.ItemId) === item.id); const matches = blocks.filter((b) => Number(b.ItemId) === item.id);
if (item.count === 0) { if (item.count === 0) {
removed.add(item.id); removed.add(item.id);
@@ -281,7 +354,7 @@
continue; continue;
} }
if (matches.length === 0) { 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; continue;
} }
const [first, ...rest] = matches; const [first, ...rest] = matches;
@@ -295,8 +368,36 @@
if (Array.isArray(slots) && removed.size) { if (Array.isArray(slots) && removed.size) {
inner.QuickSelect.Slots = slots.filter((s) => !(s && removed.has(Number(s.ItemId)))); inner.QuickSelect.Slots = slots.filter((s) => !(s && removed.has(Number(s.ItemId))));
} }
outer.Data.PlayerInventory = innerIsString ? JSON.stringify(inner) : inner; src.outer.Data.PlayerInventory = src.innerIsString ? JSON.stringify(inner) : inner;
return JSON.stringify(outer); 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) { function download(blob, name) {
@@ -312,11 +413,12 @@
async function doSave() { async function doSave() {
try { try {
const text = buildInventoryText(); const { inventoryText: text, touched } = applyChanges();
const src = state.source; const src = state.source;
let blob; let blob;
if (src.kind === "zip") { if (src.kind === "zip") {
src.zip.file(src.path, text); 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 } }); blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } });
} else { } else {
blob = new Blob([text], { type: "application/json" }); blob = new Blob([text], { type: "application/json" });
@@ -409,6 +511,7 @@
}); });
els.category.addEventListener("change", () => { state.category = els.category.value; render(); }); els.category.addEventListener("change", () => { state.category = els.category.value; render(); });
els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; 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) => { els.table.querySelector("thead").addEventListener("click", (e) => {
const th = e.target.closest("th[data-sort]"); const th = e.target.closest("th[data-sort]");
+4
View File
@@ -70,6 +70,10 @@
<input type="checkbox" id="ownedOnly"> <input type="checkbox" id="ownedOnly">
<span>Owned only</span> <span>Owned only</span>
</label> </label>
<label class="check">
<input type="checkbox" id="changedOnly">
<span>Changed only</span>
</label>
<span class="count-label" id="resultCount"></span> <span class="count-label" id="resultCount"></span>
</section> </section>