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 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
save.txt
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
nodejs 24.13.1
|
||||
35
CLAUDE.md
Normal file
35
CLAUDE.md
Normal file
@@ -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.<name>`)
|
||||
- `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.
|
||||
156
index.js
Normal file
156
index.js
Normal file
@@ -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();
|
||||
25
package-lock.json
generated
Normal file
25
package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
package.json
Normal file
16
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user