Clear hotkeys on removal, unlock blueprints, show save info, add Changed only filter
This commit is contained in:
@@ -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]");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user