From a5dd0d2397545505088b8ade5f0ab052c67e8f95 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Wed, 25 Feb 2026 12:01:15 +0100 Subject: [PATCH] Initial commit: Evolve save editor CLI Node.js CLI tool that decodes Evolve game saves (LZString compressed), edits resource values, and re-encodes back to clipboard. Features: - --max-resources: fill all capped resources to their max - --set-crafted=N: set unlimited/crafted resources to a given value - Reads from clipboard, stdin, or argument; writes back to clipboard Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + .tool-versions | 1 + CLAUDE.md | 35 +++++++++++ index.js | 156 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 25 ++++++++ package.json | 16 +++++ 6 files changed, 235 insertions(+) create mode 100644 .gitignore create mode 100644 .tool-versions create mode 100644 CLAUDE.md create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbc5991 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +save.txt diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..e151ff0 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.13.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..907c2cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CLI tool for editing [Evolve](https://pmotschmann.github.io/Evolve/) browser game save files. Saves are LZString-compressed JSON, exported via the game's Settings > Export. + +## Commands + +```bash +npm install # install dependencies (lz-string) + +# Max all capped resources, copy result to clipboard +node index.js --max-resources < save.txt +node index.js --max-resources "SAVE_STRING" + +# Print to stdout instead of clipboard +node index.js --max-resources --no-copy < save.txt +``` + +## Save Format + +Saves are LZString `compressToBase64` of a JSON object (not plain base64). Uses the `lz-string` npm package to match the game's own JS implementation. + +Key top-level sections: `resource`, `race`, `tech`, `city`, `space`, `civic`, `genes`, `prestige`, `settings`, `arpa`. + +### Resource structure (`resource.`) +- `amount`: current value +- `max`: storage cap. `> 0` = capped, `-1` = unlimited (crafted), `-2` = special/uncapped, `0` = not unlocked +- Other fields: `name`, `display`, `diff`, `delta`, `rate`, `bar`, `stackable`, `crates`, `containers`, `value`, `trade` + +## Architecture + +Single-file Node.js CLI (`index.js`), one dependency (`lz-string`). Uses `pbcopy` (macOS) or `xclip` (Linux) for clipboard. diff --git a/index.js b/index.js new file mode 100644 index 0000000..cfe0dd0 --- /dev/null +++ b/index.js @@ -0,0 +1,156 @@ +#!/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 maxResources(data) { + const changed = []; + for (const [key, res] of Object.entries(data.resource || {})) { + 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) { + const changed = []; + for (const [key, res] of Object.entries(data.resource || {})) { + 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 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; + + for (const arg of args) { + if (arg.startsWith("--set-crafted=")) { + craftedValue = Number(arg.split("=")[1]); + } else if (arg.startsWith("--")) { + flags.add(arg); + } else { + positional.push(arg); + } + } + + const hasAction = flags.has("--max-resources") || craftedValue !== null; + + if (flags.has("--help") || !hasAction) { + console.log(`Usage: node index.js [options] [save-string] + +Options: + --max-resources Set all capped resources to their max + --set-crafted=N Set all unlimited (crafted) resources to N + --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(flags.has("--help") ? 0 : 1); + } + + 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); + } + } + + const data = decodeSave(input); + + if (flags.has("--max-resources")) { + const changed = maxResources(data); + if (changed.length > 0) { + console.log(`Maxed ${changed.length} resources:`); + changed.forEach((line) => console.log(line)); + } else { + console.log("No resources to max."); + } + } + + if (craftedValue !== null) { + const changed = setCraftedResources(data, craftedValue); + 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."); + } + } + + const result = encodeSave(data); + + if (flags.has("--no-copy")) { + 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); + } +} + +main(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d1c048e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "evolve-save-editor", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "evolve-save-editor", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "lz-string": "^1.5.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac4dfbd --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "evolve-save-editor", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "lz-string": "^1.5.0" + } +}