#!/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 maxSoldiers(data) { const garrison = data.civic?.garrison; if (!garrison || !garrison.max) { return "No garrison found in save."; } const lines = []; const oldWorkers = garrison.workers ?? 0; const max = garrison.max; const oldWounded = garrison.wounded ?? 0; if (oldWorkers < max) { garrison.workers = max; lines.push(` Soldiers: ${oldWorkers} -> ${max} (max)`); } else { lines.push(` Soldiers: already at max (${max})`); } if (oldWounded > 0) { garrison.wounded = 0; lines.push(` Wounded: ${oldWounded} -> 0 (healed)`); } if (lines.length === 0) return "Garrison already at full strength."; return `Garrison:\n${lines.join("\n")}`; } 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}`; } // All pillar-able species (every race except protoplasm), in game order. // Pillar count target for the Circle of Pillars is RACES.size (63). const RACES = { human: { name: "Human", genus: "humanoid" }, elven: { name: "Elf", genus: "humanoid" }, orc: { name: "Orc", genus: "humanoid" }, cath: { name: "Cath", genus: "carnivore" }, wolven: { name: "Wolven", genus: "carnivore" }, vulpine: { name: "Vulpine", genus: "carnivore" }, centaur: { name: "Centaur", genus: "herbivore" }, rhinotaur: { name: "Rhinotaur", genus: "herbivore" }, capybara: { name: "Capybara", genus: "herbivore" }, porkenari: { name: "Porkenari", genus: "omnivore" }, hedgeoken: { name: "Hedgeoken", genus: "omnivore" }, kobold: { name: "Kobold", genus: "small" }, goblin: { name: "Goblin", genus: "small" }, gnome: { name: "Gnome", genus: "small" }, ogre: { name: "Ogre", genus: "giant" }, cyclops: { name: "Cyclops", genus: "giant" }, troll: { name: "Troll", genus: "giant" }, tortoisan: { name: "Tortoisan", genus: "reptilian" }, gecko: { name: "Gecko", genus: "reptilian" }, slitheryn: { name: "Slitheryn", genus: "reptilian" }, arraak: { name: "Arraak", genus: "avian" }, pterodacti: { name: "Pterodacti", genus: "avian" }, dracnid: { name: "Dracnid", genus: "avian" }, entish: { name: "Ent", genus: "plant" }, cacti: { name: "Cacti", genus: "plant" }, pinguicula: { name: "Pinguicula", genus: "plant" }, sporgar: { name: "Sporgar", genus: "fungi" }, shroomi: { name: "Shroomi", genus: "fungi" }, moldling: { name: "Moldling", genus: "fungi" }, mantis: { name: "Mantis", genus: "insectoid" }, scorpid: { name: "Scorpid", genus: "insectoid" }, antid: { name: "Antid", genus: "insectoid" }, sharkin: { name: "Sharkin", genus: "aquatic" }, octigoran: { name: "Octigoran", genus: "aquatic" }, dryad: { name: "Dryad", genus: "fey" }, satyr: { name: "Satyr", genus: "fey" }, phoenix: { name: "Phoenix", genus: "heat" }, salamander: { name: "Salamander", genus: "heat" }, yeti: { name: "Yeti", genus: "polar" }, wendigo: { name: "Wendigo", genus: "polar" }, tuskin: { name: "Tuskin", genus: "sand" }, kamel: { name: "Kamel", genus: "sand" }, balorg: { name: "Balorg", genus: "demonic" }, imp: { name: "Imp", genus: "demonic" }, hellspawn: { name: "Hellspawn", genus: "demonic" }, seraph: { name: "Seraph", genus: "angelic" }, unicorn: { name: "Unicorn", genus: "angelic" }, synth: { name: "Synth", genus: "synthetic" }, nano: { name: "Nano", genus: "synthetic" }, ghast: { name: "Ghast", genus: "eldritch" }, shoggoth: { name: "Shoggoth", genus: "eldritch" }, dwarf: { name: "Dwarf", genus: "hybrid" }, raccoon: { name: "Racconar", genus: "hybrid" }, lichen: { name: "Lichen", genus: "hybrid" }, wyvern: { name: "Wyvern", genus: "hybrid" }, beholder: { name: "Eye-Spector", genus: "hybrid" }, djinn: { name: "Djinn", genus: "hybrid" }, narwhal: { name: "Narwhalus", genus: "hybrid" }, bombardier: { name: "Bombardier", genus: "hybrid" }, nephilim: { name: "Nephilim", genus: "hybrid" }, junker: { name: "Valdi", genus: "special" }, sludge: { name: "Sludge", genus: "special" }, ultra_sludge: { name: "Ultra Sludge", genus: "special" }, }; const GENUS_NAMES = { humanoid: "Humanoid", carnivore: "Carnivore", herbivore: "Herbivore", omnivore: "Omnivore", small: "Small", giant: "Giant", reptilian: "Reptilian", avian: "Avian", plant: "Plant", fungi: "Fungi", insectoid: "Insectoid", aquatic: "Aquatic", fey: "Fey", heat: "Heat", polar: "Polar", sand: "Sand", demonic: "Demonic", angelic: "Angelic", synthetic: "Synthetic", eldritch: "Eldritch", hybrid: "Hybrid", special: "Special", }; function listPillars(data, showAll) { const pillars = data.pillars || {}; const current = data.race?.species; const total = Object.keys(RACES).length; const owned = Object.keys(pillars).filter((k) => pillars[k]); if (owned.length === 0 && !showAll) { console.log("No pillars in this save (Circle of Pillars not started)."); return; } // Group races by genus, keeping game order within each genus const byGenus = {}; for (const [key, race] of Object.entries(RACES)) { (byGenus[race.genus] ||= []).push(key); } console.log(`Circle of Pillars: ${owned.length} / ${total} species pillared\n`); for (const [genus, keys] of Object.entries(byGenus)) { const done = keys.filter((k) => pillars[k]); if (done.length === 0 && !showAll) continue; const label = GENUS_NAMES[genus] || genus; console.log(`${label} (${done.length}/${keys.length}):`); for (const key of keys) { const rank = pillars[key]; const name = RACES[key].name; const marker = key === current ? " <- current species" : ""; if (rank) { console.log(` [x] ${name} (${key}) - rank ${rank}${marker}`); } else if (showAll) { console.log(` [ ] ${name} (${key})${marker}`); } } } // Unknown keys (custom races or races added after this list was built) const unknown = owned.filter((k) => !RACES[k]); if (unknown.length > 0) { console.log(`Unrecognized (custom?) species:`); unknown.forEach((k) => console.log(` [x] ${k} - rank ${pillars[k]}`)); } // Pillar bonus: +1% production / +2% storage per pillar, current species counts 4x const active = owned.reduce((sum, k) => sum + (k === current ? 4 : 1), 0); console.log(`\nPillar bonus: +${active}% production, +${active * 2}% storage`); if (!showAll) { console.log(`${total - owned.length} species still missing (use --pillars-all to list them).`); } } 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 setPrestige(data, key, value) { for (const [k, entry] of Object.entries(data.prestige || {})) { if (k.toLowerCase() === key.toLowerCase()) { const old = entry.count ?? 0; entry.count = value; return ` ${k}: ${old} -> ${value}`; } } return null; } function setResource(data, key, value) { // Check prestige currencies first const prestigeResult = setPrestige(data, key, value); if (prestigeResult) return prestigeResult; const res = (data.resource || {})[key]; if (!res) { for (const [k, r] of Object.entries(data.resource || {})) { if (k.toLowerCase() === key.toLowerCase() || (r.name || "").toLowerCase() === key.toLowerCase()) { const old = r.amount ?? 0; r.amount = value; return ` ${r.name || k}: ${Math.floor(old)} -> ${value}`; } } return null; } const old = res.amount ?? 0; res.amount = value; return ` ${res.name || key}: ${Math.floor(old)} -> ${value}`; } function main() { const args = process.argv.slice(2); const flags = new Set(); const positional = []; let craftedValue = null; let onlyFilter = null; const setValues = []; // [{key, value}] for (const arg of args) { if (arg.startsWith("--set-crafted=")) { craftedValue = Number(arg.split("=")[1]); } else if (arg.startsWith("--set=")) { const parts = arg.slice("--set=".length); const eqIdx = parts.lastIndexOf("="); if (eqIdx > 0) { setValues.push({ key: parts.slice(0, eqIdx), value: Number(parts.slice(eqIdx + 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 mutates = flags.has("--max-resources") || flags.has("--max-time") || flags.has("--max-geology") || flags.has("--max-soldiers") || craftedValue !== null || setValues.length > 0; const inspects = flags.has("--list") || flags.has("--pillars") || flags.has("--pillars-all"); const hasAction = mutates || inspects; 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 --pillars Show which species have a pillar in the Circle of Pillars --pillars-all Same, but also list species without a pillar --max-resources Set all capped resources to their max --set=key=N Set a specific resource to N (e.g. --set=Plasmid=100) --set-crafted=N Set all unlimited (crafted) resources to N --only=a,b,c Only affect listed resources (comma-separated names or keys) --max-soldiers Fill garrison to max and heal all wounded --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("--pillars") || flags.has("--pillars-all")) { listPillars(data, flags.has("--pillars-all")); } if (inspects && !mutates) { 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-soldiers")) { console.log(maxSoldiers(data)); } 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 (setValues.length > 0) { for (const { key, value } of setValues) { if (isNaN(value)) { console.error(`Invalid value for ${key}. Must be a number.`); continue; } const result = setResource(data, key, value); if (result) { console.log(`Set resource:\n${result}`); } else { console.error(`Resource "${key}" not found in save.`); } } } 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-resource", name: "Set a specific resource" }, { value: "set-crafted", name: "Set crafted resources" }, { value: "max-soldiers", name: "Max soldiers & heal wounded" }, { value: "max-geology", name: "Max geology bonuses" }, { value: "max-time", name: "Max accelerated time" }, { value: "list", name: "List all resources" }, { value: "pillars", name: "Show Circle of Pillars progress" }, ...(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 === "pillars") { const showAll = await confirm({ message: "Also show species without a pillar?", default: false }); listPillars(data, showAll); 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-soldiers") { const result = maxSoldiers(data); console.log(result); if (!result.includes("No garrison") && !result.includes("already at")) { modified = true; } 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-resource") { const prestige = Object.entries(data.prestige || {}) .filter(([, p]) => (p.count ?? 0) > 0) .map(([key, p]) => ({ value: `prestige:${key}`, name: `${key} (${p.count}) [prestige]`, })); const resources = Object.entries(data.resource || {}) .filter(([, r]) => (r.amount ?? 0) > 0 || (r.max ?? 0) !== 0) .map(([key, r]) => ({ value: `resource:${key}`, name: `${r.name || key} (${Math.floor(r.amount ?? 0)}${r.max > 0 ? ` / ${r.max}` : ""})`, })); const choices = [...prestige, ...resources]; if (choices.length === 0) { console.log("No resources found."); continue; } const selected = await select({ message: "Which resource?", choices, }); const [type, resKey] = selected.split(":"); const label = type === "prestige" ? resKey : (data.resource[resKey].name || resKey); const value = await input({ message: `Set ${label} to what value?`, validate: (v) => { const n = Number(v); return !isNaN(n) && n >= 0 ? true : "Enter a non-negative number"; }, }); const result = type === "prestige" ? setPrestige(data, resKey, Number(value)) : setResource(data, resKey, Number(value)); if (result) { console.log(`Set resource:\n${result}`); modified = true; } 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();