Compare commits
10
Commits
044f9779e0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db7fd1866 | ||
|
|
7da27df0d5 | ||
|
|
ab00fb685a | ||
|
|
fc70aa66fc | ||
|
|
d2e66a79fc | ||
|
|
fa84bb6eb1 | ||
|
|
325965aa56 | ||
|
|
b667da52f7 | ||
|
|
7fc73c1f89 | ||
|
|
9e8fd5919b |
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
& {
|
||||||
|
$saves = Join-Path $env:USERPROFILE 'AppData\LocalLow\Endnight\SonsOfTheForest\Saves'
|
||||||
|
if (-not (Test-Path -LiteralPath $saves)) {
|
||||||
|
Write-Host "Sons of the Forest saves folder not found: $saves" -ForegroundColor Red
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$latest = Get-ChildItem -LiteralPath $saves -Filter 'SaveData.zip' -File -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $latest) {
|
||||||
|
Write-Host "No SaveData.zip found under $saves" -ForegroundColor Red
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Host "Latest save: $($latest.FullName)" -ForegroundColor Green
|
||||||
|
Write-Host "Last saved: $($latest.LastWriteTime)"
|
||||||
|
Write-Host "Opening Explorer. Drag SaveData.zip onto the sotf-inventory page."
|
||||||
|
Start-Process explorer.exe -ArgumentList "/select,`"$($latest.FullName)`""
|
||||||
|
}
|
||||||
@@ -14,6 +14,14 @@ Live page: https://justinoros.github.io/sotf-inventory/
|
|||||||
6. Click Save to download the new `SaveData.zip`.
|
6. Click Save to download the new `SaveData.zip`.
|
||||||
7. Replace the old `SaveData.zip` in your save folder with the new one and start the game.
|
7. Replace the old `SaveData.zip` in your save folder with the new one and start the game.
|
||||||
|
|
||||||
|
Can't find your save? Close the game, open PowerShell and paste:
|
||||||
|
|
||||||
|
```
|
||||||
|
irm https://raw.githubusercontent.com/JustinOros/sotf-inventory/main/Find-SaveData.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
It opens Explorer with your most recent `SaveData.zip` selected.
|
||||||
|
|
||||||
A loose `PlayerInventorySaveData.json` also works if you prefer to edit the extracted file.
|
A loose `PlayerInventorySaveData.json` also works if you prefer to edit the extracted file.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -33,6 +41,7 @@ Push the repo, then go to Settings, Pages, and set the source to the `main` bran
|
|||||||
* `styles.css` theme
|
* `styles.css` theme
|
||||||
* `app.js` load, edit, and save logic
|
* `app.js` load, edit, and save logic
|
||||||
* `items.js` item IDs, names, categories, and max counts
|
* `items.js` item IDs, names, categories, and max counts
|
||||||
|
* `Find-SaveData.ps1` opens Explorer at your latest `SaveData.zip`
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,31 @@
|
|||||||
const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1";
|
const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1";
|
||||||
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 FIRST_PAGE_BLUEPRINT = 665;
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const els = {
|
const els = {
|
||||||
dropZone: $("dropZone"),
|
dropZone: $("dropZone"),
|
||||||
|
dropTarget: $("dropTarget"),
|
||||||
loaderTitle: $("loaderTitle"),
|
loaderTitle: $("loaderTitle"),
|
||||||
loaderStatus: $("loaderStatus"),
|
loaderStatus: $("loaderStatus"),
|
||||||
browseBtn: $("browseBtn"),
|
browseBtn: $("browseBtn"),
|
||||||
fileInput: $("fileInput"),
|
fileInput: $("fileInput"),
|
||||||
copyPathBtn: $("copyPathBtn"),
|
copyPathBtn: $("copyPathBtn"),
|
||||||
|
copyCmdBtn: $("copyCmdBtn"),
|
||||||
|
helperCmd: $("helperCmd"),
|
||||||
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"),
|
||||||
emptyState: $("emptyState"),
|
emptyState: $("emptyState"),
|
||||||
changeSummary: $("changeSummary"),
|
changeSummary: $("changeSummary"),
|
||||||
resetBtn: $("resetBtn"),
|
resetBtn: $("resetBtn"),
|
||||||
|
clearBtn: $("clearBtn"),
|
||||||
saveBtn: $("saveBtn"),
|
saveBtn: $("saveBtn"),
|
||||||
disclaimer: $("disclaimer"),
|
disclaimer: $("disclaimer"),
|
||||||
disclaimerOk: $("disclaimerOk"),
|
disclaimerOk: $("disclaimerOk"),
|
||||||
@@ -38,6 +45,7 @@
|
|||||||
query: "",
|
query: "",
|
||||||
category: "",
|
category: "",
|
||||||
ownedOnly: false,
|
ownedOnly: false,
|
||||||
|
changedOnly: false,
|
||||||
source: null
|
source: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,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);
|
||||||
});
|
});
|
||||||
@@ -157,12 +166,17 @@
|
|||||||
return [...state.items.values()].filter((i) => i.count !== i.original);
|
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() {
|
function updateSummary() {
|
||||||
const n = changedItems().length;
|
const n = changedItems().length;
|
||||||
els.changeSummary.textContent = n === 0 ? "No changes" : `${n} item${n === 1 ? "" : "s"} changed`;
|
els.changeSummary.textContent = n === 0 ? "No changes" : `${n} item${n === 1 ? "" : "s"} changed`;
|
||||||
els.changeSummary.classList.toggle("has-changes", n > 0);
|
els.changeSummary.classList.toggle("has-changes", n > 0);
|
||||||
els.resetBtn.disabled = n === 0;
|
els.resetBtn.disabled = n === 0;
|
||||||
els.saveBtn.disabled = !state.source || n === 0;
|
els.clearBtn.disabled = !clearableItems().some((i) => i.count > 0);
|
||||||
|
els.saveBtn.disabled = !state.source;
|
||||||
els.saveBtn.title = state.source ? "" : "Load a save first";
|
els.saveBtn.title = state.source ? "" : "Load a save first";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,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}`;
|
||||||
@@ -221,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();
|
||||||
}
|
}
|
||||||
@@ -242,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) {
|
||||||
@@ -258,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);
|
||||||
@@ -271,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;
|
||||||
@@ -285,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) {
|
||||||
@@ -302,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" });
|
||||||
@@ -347,12 +459,21 @@
|
|||||||
els.copyPathBtn.addEventListener("click", async () => {
|
els.copyPathBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(SAVES_PATH);
|
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.");
|
toast("Path copied. Paste it into the file dialog's address bar, then open your SteamID and save folders.");
|
||||||
} catch {
|
} catch {
|
||||||
toast("Couldn't copy. Select the path and copy it manually.", true);
|
toast("Couldn't copy. Select the path and copy it manually.", true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
els.copyCmdBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(els.helperCmd.textContent.trim());
|
||||||
|
toast("Command copied. Paste it into PowerShell and press Enter.");
|
||||||
|
} catch {
|
||||||
|
toast("Couldn't copy. Select the command and copy it manually.", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
els.browseBtn.addEventListener("click", browse);
|
els.browseBtn.addEventListener("click", browse);
|
||||||
els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0]));
|
els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0]));
|
||||||
|
|
||||||
@@ -375,8 +496,9 @@
|
|||||||
els.dropZone.classList.remove("dragging");
|
els.dropZone.classList.remove("dragging");
|
||||||
loadFile(e.dataTransfer.files[0]);
|
loadFile(e.dataTransfer.files[0]);
|
||||||
});
|
});
|
||||||
els.dropZone.addEventListener("keydown", (e) => {
|
els.dropTarget.addEventListener("click", browse);
|
||||||
if (e.target === els.dropZone && (e.key === "Enter" || e.key === " ")) {
|
els.dropTarget.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
browse();
|
browse();
|
||||||
}
|
}
|
||||||
@@ -389,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]");
|
||||||
@@ -442,6 +565,12 @@
|
|||||||
render();
|
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", () => {
|
els.saveBtn.addEventListener("click", () => {
|
||||||
if (!state.source) return;
|
if (!state.source) return;
|
||||||
els.saveFileName.textContent = state.source.fileName;
|
els.saveFileName.textContent = state.source.fileName;
|
||||||
|
|||||||
+48
-9
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
<title>SOTF Inventory Editor</title>
|
<title>SOTF Inventory Editor</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌲</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌲</text></svg>">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
@@ -16,23 +17,56 @@
|
|||||||
<h1>Inventory</h1>
|
<h1>Inventory</h1>
|
||||||
<p class="subtitle">A save editor for Sons of the Forest. Load your save, set your counts, download the new file.</p>
|
<p class="subtitle">A save editor for Sons of the Forest. Load your save, set your counts, download the new file.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<svg class="treeline" aria-hidden="true" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<pattern id="trees" width="120" height="90" patternUnits="userSpaceOnUse">
|
||||||
|
<path transform="scale(1 1.5)" fill="currentColor" d="M0 60 L0 44 L8 30 L5 30 L12 16 L9 16 L16 2 L23 16 L20 16 L27 30 L24 30 L32 44 L32 60 Z M30 60 L38 40 L35 40 L42 26 L39 26 L46 14 L53 26 L50 26 L57 40 L54 40 L62 60 Z M58 60 L66 36 L63 36 L71 20 L68 20 L76 6 L84 20 L81 20 L89 36 L86 36 L94 60 Z M90 60 L97 44 L94 44 L101 30 L98 30 L105 20 L112 30 L109 30 L116 44 L113 44 L120 60 Z"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#trees)"/>
|
||||||
|
</svg>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="loader" id="dropZone" tabindex="0" aria-label="Load a save file">
|
<section class="loader" id="dropZone" aria-label="Load a save file">
|
||||||
<div class="loader-text">
|
<div class="loader-head">
|
||||||
<h2 id="loaderTitle">Drop your SaveData.zip here</h2>
|
<h2 id="loaderTitle">Load your save</h2>
|
||||||
<p id="loaderStatus">No save loaded. You can browse and edit items, but saving needs a loaded save.</p>
|
<p id="loaderStatus">No save loaded. You can browse and edit items, but saving needs a loaded save.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="loader-actions">
|
|
||||||
|
<div class="options">
|
||||||
|
<div class="option">
|
||||||
|
<span class="option-tag">Option A</span>
|
||||||
|
<h3>Browse to your file</h3>
|
||||||
|
<p>Your save is here:</p>
|
||||||
|
<code class="path"><span>%USERPROFILE%\<wbr>AppData\<wbr>LocalLow\<wbr>Endnight\<wbr>SonsOfTheForest\<wbr>Saves\<wbr></span><var>SteamID</var><span>\<wbr>SinglePlayer\<wbr></span><var>SaveID</var><span>\<wbr>SaveData.zip</span></code>
|
||||||
|
<p class="hint"><var>SteamID</var> is the long number folder. Pick the newest <var>SaveID</var> folder. Co-op host saves are in Multiplayer.</p>
|
||||||
|
<div class="option-actions">
|
||||||
<button type="button" class="btn" id="browseBtn">Browse</button>
|
<button type="button" class="btn" id="browseBtn">Browse</button>
|
||||||
|
<button type="button" class="btn-link copy-btn" id="copyPathBtn"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg><span>Copy path</span></button>
|
||||||
|
</div>
|
||||||
<input type="file" id="fileInput" accept=".zip,.json" hidden>
|
<input type="file" id="fileInput" accept=".zip,.json" hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="path-hint">
|
|
||||||
<span class="path-label">Your save file is here</span>
|
<div class="option">
|
||||||
<code class="path"><span>%USERPROFILE%\AppData\LocalLow\Endnight\SonsOfTheForest\Saves\</span><var>SteamID</var><span>\SinglePlayer\</span><var>SaveID</var><span>\SaveData.zip</span></code>
|
<span class="option-tag">Option B</span>
|
||||||
<p class="path-note"><var>SteamID</var> is the long number folder. <var>SaveID</var> is one folder per save; sort by date modified to find your latest. Hosted co-op saves are under Multiplayer instead of SinglePlayer.</p>
|
<h3>Use this PowerShell script</h3>
|
||||||
<button type="button" class="btn-link" id="copyPathBtn">Copy Saves folder path</button>
|
<p>Close the game, open PowerShell and paste:</p>
|
||||||
|
<code class="path" id="helperCmd">irm https://<wbr>raw.githubusercontent.com/<wbr>JustinOros/<wbr>sotf-inventory/<wbr>main/<wbr>Find-SaveData.ps1 | iex</code>
|
||||||
|
<p class="hint">Explorer opens with your latest SaveData.zip selected. Drag it onto this page.</p>
|
||||||
|
<div class="option-actions">
|
||||||
|
<button type="button" class="btn copy-btn" id="copyCmdBtn"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg><span>Copy command</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option option-drop" id="dropTarget" tabindex="0" role="button" aria-label="Drop SaveData.zip here or press Enter to browse">
|
||||||
|
<span class="option-tag">Option C</span>
|
||||||
|
<h3>Drag and drop</h3>
|
||||||
|
<div class="drop-well">
|
||||||
|
<svg viewBox="0 0 24 24" width="36" height="36" aria-hidden="true"><path d="M12 3v12m0 0-4.5-4.5M12 15l4.5-4.5M4 17v2.5A1.5 1.5 0 0 0 5.5 21h13a1.5 1.5 0 0 0 1.5-1.5V17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span>Drop your SaveData.zip here</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -45,6 +79,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>
|
||||||
|
|
||||||
@@ -68,6 +106,7 @@
|
|||||||
<footer class="savebar">
|
<footer class="savebar">
|
||||||
<span id="changeSummary">No changes</span>
|
<span id="changeSummary">No changes</span>
|
||||||
<div class="savebar-actions">
|
<div class="savebar-actions">
|
||||||
|
<button type="button" class="btn-link" id="clearBtn" title="Sets every item to 0 except equipped items and starting tools">Clear inventory</button>
|
||||||
<button type="button" class="btn-link" id="resetBtn" disabled>Undo changes</button>
|
<button type="button" class="btn-link" id="resetBtn" disabled>Undo changes</button>
|
||||||
<button type="button" class="btn btn-primary" id="saveBtn" disabled>Save</button>
|
<button type="button" class="btn btn-primary" id="saveBtn" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+117
-45
@@ -1,4 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
--spruce: #0f1512;
|
--spruce: #0f1512;
|
||||||
--spruce-2: #161f1a;
|
--spruce-2: #161f1a;
|
||||||
--spruce-3: #1f2a23;
|
--spruce-3: #1f2a23;
|
||||||
@@ -35,14 +36,14 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero::after {
|
.treeline {
|
||||||
content: "";
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
|
width: 100%;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 60' preserveAspectRatio='none'%3E%3Cpath fill='%230f1512' d='M0 60 L0 44 L8 30 L5 30 L12 16 L9 16 L16 2 L23 16 L20 16 L27 30 L24 30 L32 44 L32 60 Z M30 60 L38 40 L35 40 L42 26 L39 26 L46 14 L53 26 L50 26 L57 40 L54 40 L62 60 Z M58 60 L66 36 L63 36 L71 20 L68 20 L76 6 L84 20 L81 20 L89 36 L86 36 L94 60 Z M90 60 L97 44 L94 44 L101 30 L98 30 L105 20 L112 30 L109 30 L116 44 L113 44 L120 60 Z'/%3E%3C/svg%3E") repeat-x bottom / 120px 90px;
|
color: var(--spruce);
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-inner {
|
.hero-inner {
|
||||||
@@ -73,80 +74,150 @@ h1 {
|
|||||||
main {
|
main {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1.5rem;
|
padding: 2.5rem 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
display: grid;
|
padding: 1.75rem;
|
||||||
grid-template-columns: 1fr auto;
|
border: 1px solid var(--line);
|
||||||
gap: 1rem 1.5rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px dashed var(--moss);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--spruce-2);
|
background: var(--spruce-2);
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader.dragging {
|
.loader-head h2 {
|
||||||
border-color: var(--bone);
|
|
||||||
border-style: solid;
|
|
||||||
background: var(--spruce-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader.loaded {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader h2 {
|
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.75rem;
|
font-size: 1.9rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader p {
|
.loader-head p {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
color: var(--fog);
|
color: var(--fog);
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-hint {
|
.loader.loaded .loader-head h2 { color: #fff; }
|
||||||
grid-column: 1 / -1;
|
|
||||||
|
.options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
justify-items: start;
|
gap: 1.25rem;
|
||||||
font-size: 0.875rem;
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--spruce);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--moss);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option h3 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: -0.35rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option p {
|
||||||
|
margin: 0;
|
||||||
color: var(--fog);
|
color: var(--fog);
|
||||||
padding-top: 1rem;
|
font-size: 0.925rem;
|
||||||
border-top: 1px solid var(--line);
|
}
|
||||||
|
|
||||||
|
.option .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path {
|
.path {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
|
||||||
font-family: Consolas, "Courier New", monospace;
|
font-family: Consolas, "Courier New", monospace;
|
||||||
font-size: 0.95rem;
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.55;
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
background: var(--spruce);
|
background: var(--spruce-2);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.55rem 0.7rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: break-word;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-hint var {
|
.option var {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: #e0a39a;
|
color: #e0a39a;
|
||||||
border-bottom: 1px dotted var(--blood-hi);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-note {
|
.copy-btn {
|
||||||
margin: 0;
|
display: inline-flex;
|
||||||
max-width: 60em;
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-link.copy-btn { padding: 0; }
|
||||||
|
|
||||||
|
.copy-btn svg { flex: none; }
|
||||||
|
|
||||||
|
.btn.copy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link.copy-btn span {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-drop {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-well {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 11rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fog);
|
||||||
|
border: 2px dashed var(--moss);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-drop:hover .drop-well,
|
||||||
|
.loader.dragging .drop-well {
|
||||||
|
border-color: var(--bone);
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader.dragging { border-color: var(--moss); }
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
font: 600 1rem var(--body);
|
font: 600 1rem var(--body);
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
@@ -438,8 +509,9 @@ dialog p { margin: 0 0 0.9rem; line-height: 1.55; }
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.hero { padding: 2.5rem 1rem 5.5rem; }
|
.hero { padding: 2.5rem 1rem 5.5rem; }
|
||||||
main { padding: 0 1rem; }
|
main { padding: 1.5rem 1rem 0; }
|
||||||
.loader { grid-template-columns: 1fr; }
|
.options { grid-template-columns: 1fr; }
|
||||||
|
.drop-well { min-height: 7rem; }
|
||||||
.col-cat, .cell-cat, .col-id, .cell-id { display: none; }
|
.col-cat, .cell-cat, .col-id, .cell-id { display: none; }
|
||||||
.col-count { width: auto; }
|
.col-count { width: auto; }
|
||||||
.stepper input { width: 4rem; }
|
.stepper input { width: 4rem; }
|
||||||
|
|||||||
Reference in new issue
Block a user