Files
evolve-save-editor/index.js

424 lines
12 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
"use strict";
const LZString = require("lz-string");
const { execSync } = require("child_process");
function decodeSave(compressed) {
const json = LZString.decompressFromBase64(compressed);
if (!json) throw new Error("Failed to decompress save data");
return JSON.parse(json);
}
function encodeSave(data) {
return LZString.compressToBase64(JSON.stringify(data));
}
function matchesFilter(key, res, filter) {
if (!filter) return true;
const name = (res.name || key).toLowerCase();
return filter.has(key.toLowerCase()) || filter.has(name);
}
function maxResources(data, filter) {
const changed = [];
for (const [key, res] of Object.entries(data.resource || {})) {
if (!matchesFilter(key, res, filter)) continue;
const max = res.max ?? 0;
const amount = res.amount ?? 0;
if (max > 0 && amount < max) {
const old = amount;
res.amount = max;
changed.push(` ${res.name || key}: ${Math.floor(old)} -> ${max}`);
}
}
return changed;
}
function setCraftedResources(data, value, filter) {
const changed = [];
for (const [key, res] of Object.entries(data.resource || {})) {
if (!matchesFilter(key, res, filter)) continue;
if (res.max === -1 && (res.amount ?? 0) < value) {
const old = res.amount ?? 0;
res.amount = value;
changed.push(` ${res.name || key}: ${Math.floor(old)} -> ${value}`);
}
}
return changed;
}
function listResources(data) {
const resources = Object.entries(data.resource || {});
if (resources.length === 0) {
console.log("No resources found in save.");
return;
}
const capped = [];
const crafted = [];
const special = [];
const locked = [];
for (const [key, res] of resources) {
const name = res.name || key;
const amount = res.amount ?? 0;
const max = res.max ?? 0;
if (max > 0) {
capped.push(` ${name} (${key}): ${Math.floor(amount)} / ${max}`);
} else if (max === -1) {
crafted.push(` ${name} (${key}): ${Math.floor(amount)}`);
} else if (max === -2) {
special.push(` ${name} (${key}): ${Math.floor(amount)}`);
} else {
locked.push(` ${name} (${key})`);
}
}
if (capped.length > 0) {
console.log(`Capped resources (${capped.length}):`);
capped.forEach((l) => console.log(l));
}
if (crafted.length > 0) {
console.log(`Crafted/unlimited resources (${crafted.length}):`);
crafted.forEach((l) => console.log(l));
}
if (special.length > 0) {
console.log(`Special resources (${special.length}):`);
special.forEach((l) => console.log(l));
}
if (locked.length > 0) {
console.log(`Locked/unavailable resources (${locked.length}):`);
locked.forEach((l) => console.log(l));
}
}
function setGeology(data) {
// Max bonus depends on White Hole achievement: top = 30 + level * 5
const whLevel = data.stats?.achieve?.whitehole?.l ?? 0;
const top = 30 + whLevel * 5;
const maxBonus = (top - 10) / 100;
const geology = {
Copper: maxBonus,
Iron: maxBonus,
Aluminium: maxBonus,
Titanium: maxBonus,
};
data.city.geology = geology;
const entries = Object.entries(geology)
.map(([k, v]) => ` ${k}: +${(v * 100).toFixed(0)}%`)
.join("\n");
return `Set 4 geology bonuses (max +${(maxBonus * 100).toFixed(0)}% from White Hole level ${whLevel}):\n${entries}`;
}
function readFromClipboard() {
try {
return execSync("pbpaste", { encoding: "utf-8" }).trim();
} catch {
try {
return execSync("xclip -selection clipboard -o", { encoding: "utf-8" }).trim();
} catch {
return null;
}
}
}
function copyToClipboard(text) {
try {
execSync("pbcopy", { input: text });
return true;
} catch {
try {
execSync("xclip -selection clipboard", { input: text });
return true;
} catch {
return false;
}
}
}
function main() {
const args = process.argv.slice(2);
const flags = new Set();
const positional = [];
let craftedValue = null;
let onlyFilter = null;
for (const arg of args) {
if (arg.startsWith("--set-crafted=")) {
craftedValue = Number(arg.split("=")[1]);
} else if (arg.startsWith("--only=")) {
onlyFilter = new Set(arg.slice("--only=".length).split(",").map(s => s.trim().toLowerCase()));
} else if (arg.startsWith("--")) {
flags.add(arg);
} else {
positional.push(arg);
}
}
const hasAction = flags.has("--max-resources") || flags.has("--max-time") || flags.has("--max-geology") || flags.has("--list") || craftedValue !== null;
if (flags.has("--help")) {
console.log(`Usage: node index.js [options] [save-string]
Running with no flags launches interactive mode.
Options:
--list List all resources in the save with current values
--max-resources Set all capped resources to their max
--set-crafted=N Set all unlimited (crafted) resources to N
--only=a,b,c Only affect listed resources (comma-separated names or keys)
--max-geology Set 4 geology resource bonuses at max values
--max-time Set accelerated time (AT) to 8 hours
--no-copy Print result instead of copying to clipboard
--help Show this help
Save string can be passed as argument, piped via stdin, or read from clipboard.`);
process.exit(0);
}
if (!hasAction && process.stdin.isTTY) {
return interactiveMode();
}
if (!hasAction) {
console.error("Error: No action specified. Use --help for usage or run without flags for interactive mode.");
process.exit(1);
}
const { data, noCopy } = loadSave(positional, flags.has("--no-copy"));
if (flags.has("--list")) {
listResources(data);
if (!flags.has("--max-resources") && !flags.has("--max-geology") && !flags.has("--max-time") && craftedValue === null) {
process.exit(0);
}
}
if (flags.has("--max-resources")) {
const changed = maxResources(data, onlyFilter);
if (changed.length > 0) {
console.log(`Maxed ${changed.length} resources:`);
changed.forEach((line) => console.log(line));
} else {
console.log("No resources to max.");
}
}
if (flags.has("--max-geology")) {
console.log(setGeology(data));
}
if (flags.has("--max-time")) {
const MAX_AT = 11520; // game adjusts down to 8 hours on load
const old = data.settings?.at ?? 0;
if (old < MAX_AT) {
data.settings.at = MAX_AT;
console.log(`Accelerated time: ${old} -> ${MAX_AT} (adjusts to 8 hours on load)`);
} else {
console.log(`Accelerated time already at ${old} (>= max).`);
}
}
if (craftedValue !== null) {
const changed = setCraftedResources(data, craftedValue, onlyFilter);
if (changed.length > 0) {
console.log(`Set ${changed.length} crafted resources to ${craftedValue}:`);
changed.forEach((line) => console.log(line));
} else {
console.log("No crafted resources to set.");
}
}
outputResult(data, noCopy);
}
function loadSave(positional, noCopy) {
let input;
if (positional.length > 0) {
input = positional[0].trim();
} else if (!process.stdin.isTTY) {
try {
input = require("fs").readFileSync("/dev/stdin", "utf-8").trim();
} catch {
// stdin not readable (e.g. piped but empty), fall through to clipboard
}
}
if (!input) {
const clip = readFromClipboard();
if (clip) {
console.log("Reading save data from clipboard...");
input = clip;
} else {
console.error("Error: No save data provided. Pass as argument, pipe via stdin, or copy to clipboard.");
process.exit(1);
}
}
return { data: decodeSave(input), noCopy };
}
function outputResult(data, noCopy) {
const result = encodeSave(data);
if (noCopy) {
console.log(result);
} else if (copyToClipboard(result)) {
console.log("Edited save copied to clipboard.");
} else {
console.error("Could not copy to clipboard. Output:");
console.log(result);
}
}
function getResourceChoices(data, type) {
const choices = [];
for (const [key, res] of Object.entries(data.resource || {})) {
const name = res.name || key;
const amount = res.amount ?? 0;
const max = res.max ?? 0;
if (type === "capped" && max > 0) {
const full = amount >= max;
choices.push({
value: key,
name: `${name} (${Math.floor(amount)} / ${max})${full ? " [full]" : ""}`,
checked: !full,
});
} else if (type === "crafted" && max === -1) {
choices.push({
value: key,
name: `${name} (${Math.floor(amount)})`,
checked: true,
});
}
}
return choices;
}
async function interactiveMode() {
const { select, checkbox, confirm, input } = require("@inquirer/prompts");
const { data } = loadSave([], false);
let modified = false;
while (true) {
const action = await select({
message: "What would you like to do?",
choices: [
{ value: "max-resources", name: "Max capped resources" },
{ value: "set-crafted", name: "Set crafted resources" },
{ value: "max-geology", name: "Max geology bonuses" },
{ value: "max-time", name: "Max accelerated time" },
{ value: "list", name: "List all resources" },
...(modified ? [{ value: "save", name: "Save & copy to clipboard" }] : []),
{ value: "exit", name: "Exit" },
],
});
if (action === "exit") {
if (modified) {
const discard = await confirm({ message: "Discard unsaved changes?", default: false });
if (!discard) continue;
}
break;
}
if (action === "save") {
outputResult(data, false);
break;
}
if (action === "list") {
listResources(data);
continue;
}
if (action === "max-time") {
const MAX_AT = 11520;
const old = data.settings?.at ?? 0;
if (old < MAX_AT) {
data.settings.at = MAX_AT;
console.log(`Accelerated time: ${old} -> ${MAX_AT} (adjusts to 8 hours on load)`);
modified = true;
} else {
console.log(`Accelerated time already at ${old} (>= max).`);
}
continue;
}
if (action === "max-geology") {
console.log(setGeology(data));
modified = true;
continue;
}
if (action === "max-resources") {
const choices = getResourceChoices(data, "capped");
if (choices.length === 0) {
console.log("No capped resources found.");
continue;
}
const selected = await checkbox({
message: "Select resources to max (space to toggle, enter to confirm)",
choices,
});
if (selected.length === 0) {
console.log("No resources selected.");
continue;
}
const filter = new Set(selected.map((s) => s.toLowerCase()));
const changed = maxResources(data, filter);
if (changed.length > 0) {
console.log(`Maxed ${changed.length} resources:`);
changed.forEach((line) => console.log(line));
modified = true;
} else {
console.log("Selected resources already at max.");
}
continue;
}
if (action === "set-crafted") {
const choices = getResourceChoices(data, "crafted");
if (choices.length === 0) {
console.log("No crafted resources found.");
continue;
}
const value = await input({
message: "Set crafted resources to what value?",
validate: (v) => {
const n = Number(v);
return !isNaN(n) && n >= 0 ? true : "Enter a non-negative number";
},
});
const selected = await checkbox({
message: "Select resources to set (space to toggle, enter to confirm)",
choices,
});
if (selected.length === 0) {
console.log("No resources selected.");
continue;
}
const filter = new Set(selected.map((s) => s.toLowerCase()));
const changed = setCraftedResources(data, Number(value), filter);
if (changed.length > 0) {
console.log(`Set ${changed.length} crafted resources to ${value}:`);
changed.forEach((line) => console.log(line));
modified = true;
} else {
console.log("Selected resources already at or above target.");
}
continue;
}
}
}
main();