From b5a98685ff7f6dd721bdd2d015d24379183f2fd1 Mon Sep 17 00:00:00 2001
From: UnwrappedGodiva <UnwrappedGodiva+GitGud@gmail.com>
Date: Thu, 23 Apr 2020 00:30:16 +0200
Subject: [PATCH] New tool fc_edit_save.py to hack saved games.

---
 saveTools/README.md       |   69 ++
 saveTools/fc_edit_save.py | 2041 +++++++++++++++++++++++++++++++++++++
 2 files changed, 2110 insertions(+)
 create mode 100644 saveTools/README.md
 create mode 100755 saveTools/fc_edit_save.py

diff --git a/saveTools/README.md b/saveTools/README.md
new file mode 100644
index 00000000000..79d19492396
--- /dev/null
+++ b/saveTools/README.md
@@ -0,0 +1,69 @@
+# Tool(s) for hacking saved games
+
+The script `fc_edit_save.py` can be used to read or write FC games saved with the "Save to Disk..." option.
+It can convert the compressed files (usually named `free-cities-YYYYMMDD-HHMMSS.save`) to or from JSON format,
+view or modify some variables, search for slaves matching some criteria to view or modify them, etc.
+
+## Dependencies
+
+The script `fc_edit_save.py` requires Python version 3.6 or later.
+
+In addition, it uses a small module called `lzstring`, available via [PyPI](https://pypi.org/project/lzstring/).
+If you do not have it yet, you can install it with `pip3 install lzstring` or simply `pip install lzstring` if
+your default version of Python is already Python 3.x.  You can also install the module manually from PyPI
+without using `pip`, but then you are on your own for copying the files in the right locations.
+
+## Usage
+
+To view the help message summarizing the command-line arguments, try:
+```
+./fc_edit_save.py --help
+```
+
+To view a longer help message including details for each command and a list of examples, try:
+```
+./fc_edit_save.py --long-help
+```
+
+The basic usage consists of:
+
+ * reading an input file (compressed `*.save` file with the option `-i`, or uncompressed JSON file with the
+   option `-I`),
+
+ * optionally modifying it with various commands,
+
+ * then saving the result in some output file (compressed `*.save` file with the option `-o`, or uncompressed
+   JSON file with the option `-O`).
+
+If the options `-i`/`-I` or `-o`/`-O` are not used, then the standard input and standard output will be used, which
+allows this script to be used easily in a pipe including other commands.  If the input is a file, then the option
+`-A` can also be used instead of `-o`/`-O` to set the name of the output file automatically.  Try `--help` and
+`--long-help` for more details.
+
+## Examples
+
+The following examples assume that you already have a saved game in the file `free-cities-20200401-123456.save`.
+
+To convert a compressed FC game file to a human-readable JSON file:
+```
+./fc_edit_save.py -i free-cities-20200104-123456.save -p -O free-cities-20200104-123456.json
+```
+
+To convert a JSON file to a compressed FC game file:
+```
+./fc_edit_save.py -I free-cities-20200104-123456.json -o free-cities-20200104-123456.save
+```
+
+To display the names and current assignments of all smart slaves (with more than 50 in intelligence):
+```
+./fc_edit_save.py -i free-cities-20200104-123456.save -p --get-slaves 'intelligence>50' 'name,assignment'
+```
+
+To ensure that all your slaves have at least DD-cup boobs and change the hair of Miss Lily at random:
+```
+./fc_edit_save.py -i free-cities-20200104-123456.save -A --set-slaves all 'boobs=atleast:900' --set-slaves 'Miss Lily' randomhair -v
+```
+Note that the option `-A` in this example will save the results in the file `free-cities-20200104-123456-cheat.save`.
+The option `-v` will show the changes that are applied to all slaves.
+
+For more examples, try `--long-help`.
diff --git a/saveTools/fc_edit_save.py b/saveTools/fc_edit_save.py
new file mode 100755
index 00000000000..2fb96f25d87
--- /dev/null
+++ b/saveTools/fc_edit_save.py
@@ -0,0 +1,2041 @@
+#!/usr/bin/python3
+"""
+View or modify Free Cities save files in native format or JSON format.
+
+Run this program with --help to see a summary of its options.
+Try also --long-help to see more details and some examples.
+
+This software is released into the public domain (CC0).
+"""
+
+# Written in 2020 by UnwrappedGodiva <UnwrappedGodiva+FC@gmail.com>
+#
+# To the extent possible under law, the author(s) have dedicated all copyright
+# and related and neighboring rights to this software to the public domain
+# worldwide.  This software is distributed without any warranty.  You should
+# have received a copy of the CC0 Public Domain Dedication along with this
+# software. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
+#
+# In other words, feel free to do whatever you want with this code: modify it,
+# borrow any part of it in your own code, rewrite it in a different language,
+# remove this note and put your name on it, sell it for indecent amounts of
+# money, or redistribute it under any software license.  But there is no
+# warranty.  If it creates a giant black hole that destroys the known universe,
+# do not blame me for it.
+
+# Sanity checks that should be performed on this code on a regular basis:
+#   pylint3 ./fc_edit_save.py
+#   yapf3 --style=Google -d ./fc_edit_save.py
+# See also: http://google.github.io/styleguide/pyguide.html
+# pylint: disable=too-many-lines,too-many-statements,too-many-branches
+
+__version__ = "0.3.0"
+
+import argparse
+import copy
+import fnmatch
+import inspect
+import random
+import json
+import operator
+import os
+import re
+import shutil
+import sys
+import textwrap
+# If you do not have the lzstring module yet, you can install it with:
+# pip3 install lzstring   (format used by Twine/SugarCube save files)
+import lzstring
+
+
+def version_check():
+    """If this code compiles successfully, then the version is good enough."""
+    version = ".".join(map(str, sys.version_info))
+    return f"Format strings require python 3.6 or later.  You have {version}."
+
+
+# The following fields exist only in the PC and can be ignored in slaves.
+# This list was generated from a diff between the PC and a slave around version
+# 1065 and will have to be adjusted as new features are added to the game.
+IGNORE_IN_SLAVES = [
+    "ballsImplant", "counter.birthArcOwner", "counter.birthCitizen",
+    "counter.birthClient", "counter.birthDegenerate", "counter.birthElite",
+    "counter.birthFutaSis", "counter.birthLab", "counter.birthMaster",
+    "counter.birthOther", "counter.birthSelf", "counter.storedCum",
+    "criticalDamage", "degeneracy", "fertDrugs", "forcedFertDrugs", "newVag",
+    "origEye", "physicalImpairment", "pregMood", "refreshment",
+    "refreshmentType", "relationships", "reservedChildren",
+    "reservedChildrenNursery", "rumor", "sexualEnergy", "skill.cumTap",
+    "skill.engineering", "skill.hacking", "skill.medicine", "skill.slaving",
+    "skill.trading", "skill.warfare", "staminaPills", "title"
+]
+
+# The following fields exist only in slaves and can be ignored in the PC.
+# This list was generated from a diff between the PC and a slave, with a few
+# additions for things that might break if they are modified.  Although allowed,
+# it is probably not a good idea to change PC.career and some other fields.
+IGNORE_IN_PC = [
+    "HGExclude", "NCSyouthening", "albinismOverride", "assignment", "attrKnown",
+    "canRecruit", "choosesOwnAssignment", "choosesOwnChastity",
+    "choosesOwnClothes", "clitSetting", "counter.PCChildrenFathered",
+    "counter.PCKnockedUp", "counter.births", "counter.pitKills",
+    "counter.publicUse", "currentRules", "custom.desc", "custom.hairVector",
+    "custom.image", "custom.label", "custom.title", "custom.titleLisp", "death",
+    "devotion", "dietCum", "dietMilk", "effectiveWhoreClass", "fetishKnown",
+    "fuckdoll", "haircuts", "indenture", "indentureRestrictions",
+    "lastWeeksCashIncome", "lastWeeksRepExpenses", "lastWeeksRepIncome",
+    "lifetimeCashExpenses", "lifetimeCashIncome", "lifetimeRepExpenses",
+    "lifetimeRepIncome", "newGamePlus", "oldDevotion", "oldTrust", "onDiet",
+    "origin", "override_Arm_H_Color", "override_Brow_H_Color",
+    "override_Eye_Color", "override_H_Color", "override_Pubic_H_Color",
+    "override_Race", "override_Skin", "porn", "pregControl", "readyProsthetics",
+    "recruiter", "relation", "relationTarget", "relationship",
+    "relationshipTarget", "rivalry", "rivalryTarget", "rudeTitle", "sentence",
+    "sexAmount", "sexQuality", "skill.DJ", "skill.anal", "skill.attendant",
+    "skill.bodyguard", "skill.combat", "skill.entertainer",
+    "skill.entertainment", "skill.farmer", "skill.headGirl", "skill.madam",
+    "skill.matron", "skill.milkmaid", "skill.nurse", "skill.oral",
+    "skill.recruiter", "skill.servant", "skill.stewardess", "skill.teacher",
+    "skill.vaginal", "skill.wardeness", "skill.whore", "skill.whoring",
+    "slaveCost", "subTarget", "toyHole", "training", "trust",
+    "useRulesAssistant", "weekAcquired", "whoreClass"
+]
+
+# Aliases for field names used in --get-slaves.
+FIELD_ALIASES = {
+    "name": ["birthName", "birthSurname", "slaveName", "slaveSurname"],
+    "names": ["birthName", "birthSurname", "slaveName", "slaveSurname"],
+    "parent": ["mother", "father"],
+    "parents": ["mother", "father"]
+}
+
+# Each action can be a list of assignments or other actions that will be
+# expanded recursively.  The documentation of modify_slave() and parse_value()
+# explains how the assignments work and how special values are parsed:
+# "A~B" for a random range, "A|B" for a choice, "atleast:", "atmost:", etc.
+#
+# Improvements to consider:
+# - Change the names of the actions to match the values or ranges described in
+#   the slave variables documentation or the conditions in src/js/assayJS.js.
+# - add/remove piercings, tattoos, brand, ...
+# - add or modify rules for the RA (Here be dragons)
+SLAVE_ACTIONS = {
+    # special roles for the slaves
+    "Attendant": [
+        "blind", "fetish=submissive", "mature", "genius", "femaleorgans",
+        "career=a masseuse|a yoga instructor", "skill.attendant=atleast:200",
+        "clothes=a kimono"
+    ],
+    "Agent": [
+        "mature", "nympho", "fetish=dom", "genius", "career=an arcology owner",
+        "clothes=nice business attire"
+    ],
+    "Bodyguard": [
+        "goodshape", "muscles=96", "verytall", "skill.combat=1",
+        "career=a bodyguard|a boxer|a soldier", "skill.bodyguard=atleast:200",
+        "clothes=a military uniform|a mounty outfit|a police uniform"
+    ],
+    "Concubine": [
+        "prestige=atleast:3", "experienced", "master", "beautiful", "devoted",
+        "genius", "clothes=a ball gown|a succubus outfit|clubslut netting"
+    ],
+    "DJ": [
+        "wife", "blind", "master", "goodshape", "beautiful", "genius",
+        "career=a house DJ|a musician", "skill.DJ=atleast:200",
+        "clothes=a t-shirt and panties|a t-shirt and thong"
+    ],
+    "Farmer": [
+        "wife", "goodshape", "master", "experienced", "behavioralQuirk=funny",
+        "sexualQuirk=caring", "maleorgans", "career=a farmer|a rancher",
+        "skill.farmer=atleast:200", "clothes=Western clothing"
+    ],
+    "HeadGirl": [
+        "rules.living=luxurious", "fetish=dom", "maleorgans", "femaleorgans",
+        "master", "genius", "career=a captain|a director|a Queen",
+        "skill.headGirl=atleast:200", "clothes=a ball gown"
+    ],
+    "Head Girl": ["HeadGirl"],
+    "Headgirl": ["HeadGirl"],
+    "HG": ["HeadGirl"],
+    "Recruiter": [
+        "devoted", "genius", "master", "healthy", "beautiful", "experienced",
+        "rules.living=luxurious", "career=a princess|a spy|a talent scout",
+        "skill.recruiter=atleast:200", "clothes=a fallen nuns habit"
+    ],
+    "Madam": [
+        "wife", "master", "genius", "mature", "maleorgans",
+        "career=a banker|a madam|a pimp", "skill.madam=atleast:200",
+        "clothes=leather pants and pasties"
+    ],
+    "Matron": [
+        "sexualQuirk=caring", "fetish=dom", "mature", "genius",
+        "career=a babysitter|a nanny|a practitioner",
+        "skill.matron=atleast:200", "clothes=a slutty maid outfit|an apron"
+    ],
+    "Milkmaid": [
+        "goodshape", "master", "sexualQuirk=caring", "behavioralQuirk=funny",
+        "maleorgans", "career=a milkmaid|a shepherd",
+        "skill.milkmaid=atleast:200", "clothes=Western clothing"
+    ],
+    "MilkMaid": ["Milkmaid"],
+    "Milk Maid": ["Milkmaid"],
+    "Nurse": [
+        "wife", "fetish=dom", "goodshape", "genius", "beautiful",
+        "career=a doctor|a medic|a nurse|a surgeon", "skill.nurse=atleast:200",
+        "clothes=a slutty nurse outfit"
+    ],
+    "Teacher": [
+        "mature", "genius", "beautiful", "career=a coach|a professor|a teacher",
+        "skill.teacher=atleast:200", "clothes=a schoolgirl outfit"
+    ],
+    "Schoolteacher": ["Teacher"],
+    "Stewardess": [
+        "healthy", "mature", "genius", "nympho", "fetish=dom",
+        "career=a housekeeper|a housesitter", "skill.stewardess=atleast:200",
+        "clothes=a slutty maid outfit"
+    ],
+    "Wardeness": [
+        "wife", "nympho", "fetish=sadist", "maleorgans", "goodshape", "devoted",
+        "career=a police officer|a prison guard", "skill.wardeness=atleast:200",
+        "clothes=a red army uniform|a schutzstaffel uniform"
+    ],
+    "Lurcher": ["maleorgans", "devoted", "goodshape"],
+    # fun combos
+    "perfect": [
+        "healthy", "genius", "devoted", "goodshape", "tall", "master",
+        "behaves", "young"
+    ],
+    "boobdoll": ["healthy", "goodshape", "hugeboobs"],
+    "slut": ["healthy", "goodshape", "tall", "bigboobs", "nympho"],
+    "amazon": ["healthy", "goodshape", "verytall", "muscular", "bigboobs"],
+    "bimbo": [
+        "healthy", "beautiful", "fakeboobs", "fakebutt", "lips=80", "dumb",
+        "clothes=a bimbo outfit"
+    ],
+    "cow": ["hugeboobs", "lactating", "counter.milk=atleast:2000~10000"],
+    "slug": ["nolegs", "vagina=atleast:4", "vaginaLube=atleast:2"],
+    "futa": ["maleorgans", "femaleorgans", "boobs=atleast:500"],
+    "sissy": ["dick=1", "balls=1", "scrotum=1", "trust=-50"],
+    "wellspring": [
+        "young", "healthy", "vagina=3", "anus=3", "ovaries=1", "dick=5",
+        "balls=5", "prostate=1", "lactating", "hugeboobs"
+    ],
+    "onahole": ["femaleorgans", "fetish=mindbroken", "mute", "deaf", "nolimbs"],
+    # health
+    "healthy": [
+        "health.condition=atleast:100", "health.health=atleast:100",
+        "health.illness=0", "health.longDamage=0", "health.shortDamage=0",
+        "health.tired=0", "minorInjury=0", "eye.left.type=1",
+        "eye.left.vision=2", "eye.right.type=1", "eye.right.vision=2",
+        "eyewear=none", "hears=0", "earImplant=0", "earwear=none", "smells=0",
+        "tastes=0", "heels=0", "voice=2", "voiceImplant=0", "electrolarynx=0",
+        "teeth=normal", "vaginaLube=1", "preg=atleast:-1", "chem=0", "addict=0",
+        "burst=0", "hasarms", "haslegs"
+    ],
+    "blind": [
+        "eye.left.type=1", "eye.left.vision=0", "eye.right.type=1",
+        "eye.right.vision=0", "eyewear=none"
+    ],
+    "mute": ["voice=0"],
+    "deaf": ["hears=0"],
+    "hasarms": ["arm.left.type=1", "arm.right.type=1"],
+    "haslegs": ["leg.left.type=1", "leg.right.type=1"],
+    "haslimbs": ["hasarms", "haslegs"],
+    "noarms": ["arm.left=null", "arm.right=null"],
+    "nolegs": ["leg.left=null", "leg.right=null"],
+    "nolimbs": ["noarms", "nolegs"],
+    # intelligence
+    "smart":
+    ["intelligence=atleast:50~100", "intelligenceImplant=30", "accent=0"],
+    "genius": ["intelligence=100", "intelligenceImplant=30", "accent=0"],
+    "dumb": ["intelligence=atmost:-50~-16", "intelligenceImplant=0"],
+    # trust and devotion
+    "devoted": ["trust=100", "oldTrust=100", "devotion=100", "oldDevotion=100"],
+    # body shape and beauty
+    "goodshape": [
+        "weight=0", "muscles=31~50", "waist=atmost:-50~-10", "shoulders=0",
+        "shouldersImplant=0", "hips=0~3", "hipsImplant=0", "boobsImplant=0",
+        "boobsImplantType=none", "butt=1~4", "buttImplant=0",
+        "buttImplantType=none", "face=atleast:50~100", "faceImplant=0",
+        "faceShape=normal|cute|sensual|exotic", "lips=21~70", "lipsImplant=0",
+        "bellySag=0", "belly=0", "bellyImplant=0"
+    ],
+    "beautiful": [
+        "waist=atmost:-95~-30", "lips=41~71", "anus=atmost:2",
+        "vagina=atmost:2", "face=100", "faceShape=exotic", "teeth=normal",
+        "minorInjury=0", "scar={}", "makeup=3", "nails=2", "health.tired=0",
+        "boobShape=perky", "nipples=huge", "muscles=-4",
+        "underArmHStyle=hairless|bald|waxed|shaved",
+        "pubicHStyle=hairless|bald|waxed", "prestige=atleast:3",
+        "porn.prestige=atleast:3"
+    ],
+    "petite": ["height=110~149", "heightImplant=0"],
+    "short": ["height=150~159", "heightImplant=0"],
+    "average": ["height=160~169", "heightImplant=0"],
+    "tall": ["height=170~185", "heightImplant=0"],
+    "verytall": ["height=186~210", "heightImplant=0"],
+    "giant": ["height=atleast:220", "heightImplant=0"],
+    "veryweak": ["muscles=-95~-31"],
+    "weak": ["muscles=-30~-6"],
+    "toned": ["muscles=6~30"],
+    "fit": ["muscles=31~50"],
+    "muscular": ["muscles=51~95"],
+    "maleorgans": [
+        "dick=inrange:2:3", "prostate=atleast:1", "balls=atleast:4",
+        "scrotum=atleast:4", "pubertyXY=1"
+    ],
+    "XYorgans": ["maleorgans"],
+    "femaleorgans": [
+        "vagina=inrange:0:2", "vaginaLube=atleast:1", "ovaries=1",
+        "preg=atleast:-1", "pubertyXX=1"
+    ],
+    "XXorgans": ["femaleorgans"],
+    # boobs
+    "bigboobs": [
+        "boobs=atleast:3000~5100", "boobsImplant=0", "boobsImplantType=none",
+        "boobShape=normal|perky|wide-set|torpedo-shaped", "nipples=cute|huge"
+    ],
+    "fakeboobs": [
+        "boobs=atleast:3000~5100", "boobsImplant=600~1000",
+        "boobsImplantType=fillable", "boobShape=perky", "nipples=huge|fuckable"
+    ],
+    "hugeboobs": [
+        "boobs=atleast:6000~10000", "boobsImplant=0", "boobsImplantType=none",
+        "boobShape=perky|wide-set|torpedo-shaped", "nipples=huge"
+    ],
+    "lactating": [
+        "lactation=1", "lactationDuration=2",
+        "lactationAdaptation=atleast:51~100", "rules.lactation=maintain"
+    ],
+    # butt
+    "fakebutt": ["butt=3", "buttImplant=2", "buttImplantType=fillable"],
+    # hair
+    "randomhair": [
+        "randomhaircolor", "randomhairlength", "randomhairstyle",
+        "randompubichairstyle"
+    ],
+    "copyhaircolor": [
+        "pubicHColor=ref:hColor", "eyebrowHColor=ref:hColor",
+        "underArmHColor=ref:hColor"
+    ],
+    "randomhaircolor": [
+        "hColor=blonde|golden|platinum blonde|strawberry-blonde|copper"
+        "|ginger|red|green|blue|pink|dark brown|brown|auburn|burgundy"
+        "|chocolate brown|chestnut|hazel|black|grey|silver|white",
+        "copyhaircolor"
+    ],
+    "lighthaircolor": [
+        "hColor=blonde|golden|platinum blonde|strawberry-blonde|copper"
+        "|ginger|red|silver|white", "copyhaircolor"
+    ],
+    "darkhaircolor": [
+        "hColor=dark brown|brown|auburn|burgundy|chocolate brown|chestnut"
+        "|hazel|black|grey", "copyhaircolor"
+    ],
+    "funhaircolor": ["hColor=red|green|blue|pink|white", "copyhaircolor"],
+    "randomhairlength": ["hLength=0~150"],
+    "verylonghair": ["hLength=100~149"],
+    "longhair": ["hLength=30~99"],
+    "mediumhair": ["hLength=10~19"],
+    "shoulderhair": ["mediumhair"],
+    "shorthair": ["hLength=0~9"],
+    "randomhairstyle": [
+        "hStyle=shaved|buzzcut|trimmed|afro|cornrows|bun|neat|strip|tails"
+        "|up|ponytail|braided|dreadlocks|permed|curled|luxurious|bald"
+        "|messy bun|messy"
+    ],
+    "randompubicstyle": [
+        "pubicHStyle=hairless|waxed|in a strip|neat|bushy|very bushy"
+        "|bushy in the front and neat in the rear|bald"
+    ],
+    "randompubichairstyle": ["randompubicstyle"],
+    # eyes
+    "randomeyecolor": [
+        "eye.left.iris=blue|black|brown|green|turquoise|sky-blue|hazel"
+        "|pale-grey|white|pink|amber|red", "eye.right.iris=ref:eye.left.iris"
+    ],
+    "lighteyecolor": [
+        "eye.left.iris=green|turquoise|sky-blue|pale-grey",
+        "eye.right.iris=ref:eye.left.iris"
+    ],
+    "darkeyecolor": [
+        "eye.left.iris=black|brown|hazel|amber",
+        "eye.right.iris=ref:eye.left.iris"
+    ],
+    "funeyecolor":
+    ["eye.left.iris=white|pink|red", "eye.right.iris=ref:eye.left.iris"],
+    # age
+    "teen": [
+        "actualAge=10~19", "physicalAge=ref:actualAge",
+        "visualAge=ref:actualAge", "ovaryAge=ref:actualAge", "ageImplant=0"
+    ],
+    "young": [
+        "actualAge=18~25", "physicalAge=ref:actualAge",
+        "visualAge=ref:actualAge", "ovaryAge=ref:actualAge", "ageImplant=0"
+    ],
+    "mature": [
+        "actualAge=36~40", "physicalAge=ref:actualAge",
+        "visualAge=ref:actualAge", "ovaryAge=30", "ageImplant=0"
+    ],
+    # skills and experience
+    "skilled": [
+        "skill.vaginal=atleast:51~100", "skill.oral=atleast:51~100",
+        "skill.anal=atleast:51~100", "skill.whoring=atleast:51~100",
+        "skill.entertainment=atleast:51~100", "skill.combat=1"
+    ],
+    "master": [
+        "skill.vaginal=100", "skill.oral=100", "skill.anal=100",
+        "skill.whoring=100", "skill.entertainment=100", "skill.combat=1"
+    ],
+    "allroles": [
+        "skill.headGirl=100~200", "skill.recruiter=100~200",
+        "skill.bodyguard=100~200", "skill.madam=100~200", "skill.DJ=100~200",
+        "skill.nurse=100~200", "skill.teacher=100~200",
+        "skill.attendant=100~200", "skill.matron=100~200",
+        "skill.stewardess=100~200", "skill.milkmaid=100~200",
+        "skill.farmer=100~200", "skill.wardeness=100~200",
+        "skill.servant=100~200", "skill.entertainer=100~200",
+        "skill.whore=100~200"
+    ],
+    "experienced": [
+        "counter.vaginal=atleast:1000~3000", "counter.anal=atleast:1000~3000",
+        "counter.penetrative=atleast:1500~5000",
+        "counter.oral=atleast:1000~3000", "counter.mammary=atleast:1000~3000"
+    ],
+    # behavior, fetishes, flaws and quirks
+    "behaves": [
+        "energy=atleast:50~100", "attrXX=atleast:50~100",
+        "attrXY=atleast:50~100", "attrKnown=1", "fetishKnown=1",
+        "behavioralFlaw=none", "sexualFlaw=none"
+    ],
+    "nympho": [
+        "energy=100", "attrXX=100", "attrXY=100", "attrKnown=1",
+        "fetishKnown=1", "fetish=none|submissive|boobs|dom|pregnancy",
+        "fetishStrength=100", "behavioralFlaw=none", "sexualFlaw=none",
+        "behavioralQuirk=none|none|none|none|confident|cutting|funny|fitness"
+        "|sinful|advocate",
+        "sexualQuirk=none|none|none|none|tease|romantic|perverted|caring"
+        "|unflinching|size queen"
+    ],
+    # relationships
+    "wife": ["relationship=-3", "relationshipTarget=-1"],
+}
+
+
+class FCBaseError(Exception):
+    """Base class for exceptions defined in this file."""
+
+
+class FCNotFoundError(FCBaseError):
+    """Exception raised when a dotted path leads nowhere."""
+
+
+class FCTypeError(FCBaseError):
+    """Exception raised when a dotted path leads to a wrong type."""
+
+
+class FCParamsError(FCBaseError):
+    """Exception raised when a command-line parameter is incorret."""
+
+
+class FCBadSelectorError(FCParamsError):
+    """Exception raised when a selector returns no results."""
+
+
+class AppendConstAction(argparse.Action):
+    """argparse.Action similar to "append" but using a tuple (`const`, values).
+    """
+
+    def __init__(self, option_strings, dest, nargs=None, const=None, **kwargs):
+        if nargs is None:
+            raise ValueError("nargs must be supplied")
+        if const is None:
+            raise ValueError("const must be supplied")
+        super(AppendConstAction, self).__init__(
+            option_strings, dest, nargs=nargs, const=const, **kwargs)
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        items = getattr(namespace, self.dest, None)
+        if items is None:
+            items = []
+        else:
+            items = items[:]
+        items.append((self.const, values))
+        setattr(namespace, self.dest, items)
+
+
+class CommandGroupContainer:
+    """Wrapper for an ArgumentParser that adds actions in a list."""
+
+    def __init__(self, arg_parser=None, dest=None, action=AppendConstAction):
+        if arg_parser is None:
+            raise ValueError("arg_parser must be supplied")
+        if not callable(getattr(arg_parser, "add_argument", None)):
+            raise ValueError("arg_parser does not look like an ArgumentParser")
+        if dest is None:
+            raise ValueError("dest must be supplied")
+        self.arg_parser = arg_parser
+        self.dest = dest
+        self.action = action
+        self.help = []
+
+    def command_from_docstring(self, func):
+        """Registers a function in the ArgumentParser based on its docstring."""
+        if func is None:
+            raise ValueError("func must be supplied")
+        if not func.__doc__:
+            message = "cannot register function {} without docstring"
+            raise ValueError(message.format(func.__name__))
+        options = []
+        arguments = []
+        help_lines = []
+        long_help_lines = None
+        re_command = re.compile(r'^\s+Command:\s+(.+?)\s*$')
+        for line in func.__doc__.splitlines():
+            if not options:
+                re_match = re_command.match(line)
+                if re_match:
+                    for arg in re_match.group(1).split():
+                        if arg.startswith("-") and not arguments:
+                            options.append(arg)
+                        else:
+                            arguments.append(arg)
+            elif long_help_lines is None:
+                if line and not line.isspace():
+                    help_lines.append(line)
+                else:
+                    long_help_lines = []
+            else:
+                long_help_lines.append(line)
+        if not options:
+            message = "cannot find 'Command:' in docstring for function '{}'"
+            raise ValueError(message.format(func.__name__))
+        # Compare the function signature with its docstring here to catch
+        # errors early instead of waiting until the function is called.
+        expected_args = len(inspect.signature(func).parameters) - 2
+        if expected_args != len(arguments):
+            message = ("function '{}' expects {} arguments after save_obj but"
+                       " its docstring shows {}")
+            raise ValueError(
+                message.format(func.__name__, expected_args, len(arguments)))
+        # Now register this function, its help text, and number of arguments.
+        short_help = textwrap.dedent("\n".join(help_lines)).strip()
+        self.arg_parser.add_argument(
+            *options,
+            action=self.action,
+            const=func,
+            dest=self.dest,
+            nargs=len(arguments),
+            metavar=tuple(arguments),
+            help=short_help)
+        # Save the long help texts.  It would be nicer to store this in the
+        # ArgumentParser and retrieve it later, but I could not find an
+        # elegant way to do that without using its private classes.  The
+        # handling of long help texts and the method format_long_help()
+        # should probably be rewritten.
+        long_help = textwrap.dedent("\n".join(long_help_lines)).strip()
+        self.help.append([options, arguments, short_help, long_help])
+
+    def format_long_help(self):
+        """Returns a list of long help texts for all commands."""
+        help_sections = []
+        for help_list in self.help:
+            for opt in help_list[0]:
+                if opt.startswith("--"):
+                    break
+            else:
+                opt = help_list[0][0]
+            help_sections.append("\n".join([
+                " ".join([opt] + help_list[1]), help_list[2], "", help_list[3]
+            ]))
+        return help_sections
+
+
+class ParagraphWrapper:
+    """Wrapper for TextWrapper, formatting text and lists in paragraphs.
+
+    This class splits the text in paragraphs separated by blank lines.  Each
+    paragraph is wrapped separately.  If a paragraph starts with "* " or "- ",
+    it is considered as a list: the lines following the first one will be
+    indented by two spaces.
+
+    If the text is already indented (e.g., in a docstring), it will first be
+    de-indented (using textwrap.dedent()) before the line wrapping is done.
+    Additional blank lines before or after the text will be stripped.
+    """
+
+    def __init__(self, width=70, indent=None):
+        if indent is None:
+            indent = ""
+        elif isinstance(indent, int):
+            indent = " " * indent
+        self.wrapper = textwrap.TextWrapper(
+            width=width,
+            initial_indent=indent,
+            subsequent_indent=indent,
+            fix_sentence_endings=True,
+            break_long_words=False,
+            break_on_hyphens=False)
+        self.wrapper_list = textwrap.TextWrapper(
+            width=width,
+            initial_indent=indent,
+            subsequent_indent=indent + "  ",
+            fix_sentence_endings=True,
+            break_long_words=False,
+            break_on_hyphens=False)
+
+    def format_paragraphs(self, text):
+        """Wraps `text` to the specified width, preserving paragraph breaks."""
+        return "\n\n".join([
+            self.wrapper_list.fill(para) if para.startswith("* ") or
+            para.startswith("- ") else self.wrapper.fill(para)
+            for para in textwrap.dedent(text).strip().split("\n\n")
+        ])
+
+
+def deepmerge(dst, src, conflict_fail=False, list_skip_none=True):
+    """Recursively merges the content of src into dst, modifying it as needed.
+
+    This is function is designed to merge objects converted to and from JSON
+    so it handles dictionaries, lists and scalar objects but not sets nor
+    other classes.
+
+    FIXME: obsolete description
+    If both objects are dictionaries, then their keys are merged recursively.
+    If both objects are lists, then the elements of src replace those of dst,
+    unless an element of src is None and list_skip_none is True.  In all other
+    cases src replaces dst, except if conflict_fail is True in which case a
+    TypeError exception is thrown.
+
+    After writing this code, I found a more complete (and more complex)
+    implementation of JSON deep merge: https://github.com/avian2/jsonmerge
+    but I think that the short code included here is good enough for what we
+    need to do with the subset of JSON used in Twine/SugarCube save files.
+
+    Args:
+        dst: The object to be modified.
+        src: The object from which the data will be copied.
+        conflict_fail: If True, an exception is thrown if src and dest are not
+            both dictionaries or lists.
+        list_skip_none: If True and both src and dst are lists, then the
+            elements of src that are None will be skipped.  If False and both
+            src and dst are lists, then all elements of src will replace those
+            of dst even if they are None.
+
+    Returns:
+        The results of the merge, which may be dst (modified) or src.
+
+    Raises:
+        FCTypeError: If src and dst are not both dictionaries or lists and if
+            conflict_fail is True.
+    """
+    if isinstance(dst, dict) and isinstance(src, dict):
+        for key in src:
+            if key in dst:
+                dst[key] = deepmerge(
+                    dst[key],
+                    src[key],
+                    conflict_fail=conflict_fail,
+                    list_skip_none=list_skip_none)
+            else:
+                dst[key] = src[key]
+        return dst
+    if isinstance(dst, list) and isinstance(src, list):
+        for i in range(min(len(src), len(dst))):
+            if not list_skip_none or src[i] is not None:
+                dst[i] = deepmerge(
+                    dst[i],
+                    src[i],
+                    conflict_fail=conflict_fail,
+                    list_skip_none=list_skip_none)
+        if len(src) > len(dst):
+            dst.extend(src[len(dst):])
+        return dst
+    if ((dst is None or isinstance(dst, (str, int, float, bool))) and
+            (src is None or isinstance(src, (str, int, float, bool)))):
+        return src
+    if conflict_fail:
+        raise FCTypeError(f"Cannot merge {src.__class__.__name__} into "
+                          "{dst.__class__.__name__}")
+    return src
+
+
+def deepfind(src,
+             regex_key=None,
+             regex_val=None,
+             path=None,
+             callback=None,
+             context=None):
+    """Searches recursively in `src` for a matching key name or value."""
+    found = 0
+    if path is None:
+        path = []
+    if isinstance(src, str):
+        if regex_val is not None and regex_val.search(src):
+            if callback:
+                callback(path, src, context)
+            found += 1
+    elif isinstance(src, dict):
+        for key in src:
+            path.append(key)
+            if regex_key is not None and regex_key.search(key):
+                if callback:
+                    callback(path, src[key], context)
+                found += 1
+            found += deepfind(
+                src[key],
+                regex_key=regex_key,
+                regex_val=regex_val,
+                path=path,
+                callback=callback,
+                context=context)
+            path.pop()
+    elif isinstance(src, list):
+        # pylint: disable=consider-using-enumerate
+        for idx in range(len(src)):
+            path.append(idx)
+            found += deepfind(
+                src[idx],
+                regex_key=regex_key,
+                regex_val=regex_val,
+                path=path,
+                callback=callback,
+                context=context)
+            path.pop()
+    return found
+
+
+def dotted_to_str(path):
+    """Converts a list of strings and ints into a Javascript-like dotted path.
+
+    This is the opposite of parse_dotted().
+    """
+    return ".".join([
+        f"[{part}]" if isinstance(part, int) else part for part in path
+    ]).replace(".[", "[")
+
+
+def parse_dotted(dotted_path):
+    """Converts a Javascript-like dotted path to a list of strings and ints
+
+    For example, the path "foo.bar[42][0].baz" will be decoded as a list
+    ["foo", "bar", 42, 0, "baz"]
+    """
+    if dotted_path == "":
+        return []
+    re_idx = re.compile(r'^(.+)\[(\d+)\]$')
+    plist = []
+    for part in dotted_path.split("."):
+        rlist = []
+        re_idx_match = re_idx.match(part)
+        while re_idx_match:
+            part = re_idx_match.group(1)
+            rlist.append(int(re_idx_match.group(2)))
+            re_idx_match = re_idx.match(part)
+        plist.append(part)
+        plist.extend(reversed(rlist))
+    return plist
+
+
+def get_dotted(src, dotted_path, null_parent_ok=False):
+    """Returns the object found at `dotted_path` in `src`."""
+    # pylint: disable=too-many-branches
+    plist = parse_dotted(dotted_path)
+    obj = src
+    done = "."
+    for part in plist:
+        if obj is None:
+            if null_parent_ok:
+                return None
+            raise FCNotFoundError(f"cannot get field '{part}' from a null "
+                                  f"object: '{done}'")
+        if isinstance(part, int):
+            if not isinstance(obj, list):
+                raise FCTypeError(f"cannot get index {part} from object that "
+                                  f"is not a list: '{done}'")
+            elif part >= len(obj):
+                raise FCNotFoundError(f"cannot get index {part} from list of "
+                                      f"length {len(obj)}: '{done}'")
+            else:
+                obj = obj[part]
+                done = done + "[" + str(part) + "]"
+        else:
+            if not isinstance(obj, dict):
+                raise FCTypeError(f"cannot get field '{part}' from a list: "
+                                  f"'{done}'")
+            elif not part in obj:
+                raise FCNotFoundError(f"field '{part}' not found in '{done}'")
+            else:
+                obj = obj[part]
+                if done == ".":
+                    done = part
+                else:
+                    done = done + "." + part
+    return obj
+
+
+def create_dotted(dotted_path, value):
+    """Creates an object that contains `value` at `dotted_path`."""
+    plist = parse_dotted(dotted_path)
+    obj = value
+    for part in reversed(plist):
+        if isinstance(part, int):
+            new_list = []
+            for _ in range(part):
+                new_list.append(None)
+            new_list.append(obj)
+            obj = new_list
+        else:
+            new_dict = {}
+            new_dict[part] = obj
+            obj = new_dict
+    #print(f"create_dotted({dotted_path}, {value}): {obj}")
+    return obj
+
+
+def set_dotted(src, dotted_path, value, verbosity=None, conflict_fail=True):
+    """Changes the value of `dotted_path` in `src` to `value`."""
+    if verbosity:
+        old_value = get_dotted(src, dotted_path, null_parent_ok=True)
+        if value == old_value:
+            if verbosity >= 2:
+                print(" - {} = {}".format(dotted_path, value))
+        else:
+            if verbosity >= 1:
+                print(" - {} changed from {} to {}".format(
+                    dotted_path, old_value, value))
+    return deepmerge(
+        src, create_dotted(dotted_path, value), conflict_fail=conflict_fail)
+
+
+def top_path_aliases(dotted_path):
+    """Substitutes the aliases $V.*, $PC.*, slaves[*] in a top-level path."""
+    rematch = re.match(r'^\$?V\.(\w.*)$', dotted_path)
+    if rematch:
+        dotted_path = "state.delta[0].variables." + rematch.group(1)
+    else:
+        rematch = re.match(r'^slaves?(\[\d.*)$', dotted_path)
+        if rematch:
+            dotted_path = "state.delta[0].variables.slaves" + rematch.group(1)
+        else:
+            rematch = re.match(r'^\$?PC\.(\w.*)$', dotted_path)
+            if rematch:
+                dotted_path = "state.delta[0].variables.PC." + rematch.group(1)
+    return dotted_path
+
+
+def parse_value(value, context_obj, path, ref_aliases=False):
+    """Parses the string `value`, which may refer to `path` in `context_obj`.
+
+    See also the documentation for command_set(), which gives more details.
+    """
+    # pylint: disable=no-member,too-many-branches
+    # if value starts with "string:", return it as is without processing
+    if value.startswith("string:"):
+        return value[7:]
+    # pick a random sub-expression if any "|" separators are present
+    if "|" in value:
+        value = random.choice(value.split("|"))
+    # check for prefixes requiring a comparison with the old value
+    compare_old = None
+    if value.startswith("atleast:"):
+        value = value[8:]
+        compare_old = "max"
+    elif value.startswith("atmost:"):
+        value = value[7:]
+        compare_old = "min"
+    elif value.startswith("inrange:"):
+        value = value[8:]
+        compare_old = "range"
+    # special values: A~B (random range), JSON values, or path to other value
+    if "~" in value:
+        vmin, vmax = value.split("~", 1)
+        value = random.randint(int(vmin), int(vmax))
+    elif re.match(r'^-?\d+$', value):
+        value = int(value)
+    elif re.match(r'^-?\d?\.\d+$', value):
+        value = float(value)
+    elif value == "null":
+        value = None
+    elif value == "true":
+        value = True
+    elif value == "false":
+        value = False
+    elif ((value.startswith("{") and value.endswith("}")) or
+          (value.startswith("[") and value.endswith("]"))):
+        value = json.loads(value)
+    elif value.startswith("ref:"):
+        if ref_aliases:
+            value = get_dotted(
+                context_obj, top_path_aliases(value[4:]), null_parent_ok=True)
+        else:
+            value = get_dotted(context_obj, value[4:], null_parent_ok=True)
+    # now that we have the value, compare it with the old value if required
+    if compare_old is not None:
+        old_value = get_dotted(context_obj, path)
+        if compare_old == "max":
+            value = max(int(value), int(old_value))
+        elif compare_old == "min":
+            value = min(int(value), int(old_value))
+        elif compare_old == "range":
+            vmin, vmax = value.split(":", 1)
+            value = min(int(vmax), max(int(vmin), int(old_value)))
+        else:
+            raise RuntimeError("invalid compare_old")
+    return value
+
+
+def fnmatch_slave_name(slave, name):
+    """Returns true if the `slave` matches the `name` (with shell wildcards)."""
+    field_names = ["slaveName", "slaveSurname", "birthName", "birthSurname"]
+    regex = re.compile(fnmatch.translate(name))
+    for field in field_names:
+        if field in slave and slave[field] and regex.match(slave[field]):
+            return True
+    return False
+
+
+def select_slaves(slaves, selector, show_fields=None):
+    """Returns a list of slaves matching `selector`.
+
+    The `selector` is usually a comma-separated list of conditions but it also
+    accepts as special cases "all" or "*" to select all slaves, or square
+    brackets including a number or a list of numbers representing a list of
+    indexes in the `slaves` array.
+
+    The standard conditions can be just a number matching a slave ID, or a
+    slave name (with shell wildcards), or a comparison between a variable
+    given by its dotted path and a scalar value (integer or string).
+
+    Note that `show_fields` is modified if present.  Tt should be a set that
+    will get additional elements for each dotted path found in the `selector`.
+    """
+    if show_fields is None:
+        show_fields = set()
+    selected = []
+    # "all" or "All" or "*" => select all slaves
+    if selector in {"all", "All", "*"}:
+        return slaves
+    # number or list of numbers in square brackets => array indexes
+    rematch = re.match(r'^\[(\d+[,\d\s]*)\]$', selector)
+    if rematch:
+        for idx in re.split(r',\s*', rematch.group(1)):
+            selected.append(slaves[int(idx)])
+        return selected
+    # list of comparisons (field=value, field<value, ...) or slave IDs or names
+    for slave in slaves:
+        for expr in re.split(r',\s*', selector):
+            # if the condition is just a number, try to match a slave ID
+            nummatch = re.match(r'^(\d+)$', expr)
+            if nummatch and slave["ID"] == int(nummatch.group(1)):
+                selected.append(slave)
+                show_fields.add("ID")
+                break
+            # try different comparison operators: <=, <, >=, >, !=, ==, =
+            for op_str, op_class, op_func in [
+                    ("<=", int, operator.le),
+                    ("<", int, operator.lt),
+                    (">=", int, operator.ge),
+                    (">", int, operator.gt),
+                    ("!=", str, operator.ne),
+                    ("==", str, operator.eq),
+                    ("=", str, operator.eq),
+            ]:
+                rematch = re.match(rf'^(.+?)\s*{op_str}\s*(.+)$', expr)
+                if rematch:
+                    break
+            else:
+                op_func = None
+            if op_func:
+                if op_func(
+                        op_class(get_dotted(slave, rematch.group(1))),
+                        op_class(rematch.group(2))):
+                    selected.append(slave)
+                    show_fields.add(rematch.group(1))
+                    break
+                else:
+                    continue
+            # else try to match a slave name (shell wildcards allowed)
+            if fnmatch_slave_name(slave, expr):
+                selected.append(slave)
+                show_fields.add("name")
+                break
+    # return what was found (maybe nothing)
+    return selected
+
+
+def expand_action(actions_dict, action):
+    """Returns the expanded `action` from `actions_dict`, recursively."""
+    actions_list = []
+    for subaction in actions_dict[action]:
+        if subaction in actions_dict:
+            actions_list += expand_action(actions_dict, subaction)
+        elif "=" in subaction:
+            actions_list.append(subaction)
+        else:
+            raise FCParamsError("unknown action: '{}' used in "
+                                "'{}'".format(subaction, action))
+    return actions_list
+
+
+def modify_slave(slave, actions_list, verbosity=None, ignore=None):
+    """Modifies a slave or PC according to a list of actions.
+
+    Each action can be a list of assignments or other actions that will be
+    expanded recursively.  Assignments must contain an equal sign ("=") and
+    will assign a new value to a field in the current slave or PC.  Special
+    values are recognized by parse_value().
+
+    Args:
+        slave: The slave or PC to be modified.
+        actions_list: The list of modifications to be applied.
+        verbose: If true, print the list of changes.
+        ignore: List of fields that should be ignored if found in an assignment.
+    """
+    if verbosity and verbosity >= 1:
+        if slave["slaveName"]:
+            print(f'Actions for slave {slave["ID"]} "{slave["slaveName"]}":'
+                  f' {actions_list}')
+        else:
+            print(f'Actions for slave {slave["ID"]}: {actions_list}')
+    if ignore is None:
+        ignore = []
+    assignments = []
+    for action in actions_list:
+        if action in SLAVE_ACTIONS:
+            assignments += expand_action(SLAVE_ACTIONS, action)
+        elif "=" in action:
+            assignments.append(action)
+        elif action == "":
+            pass
+        else:
+            raise FCParamsError("unknown action: '{}'".format(action))
+    for assignment in assignments:
+        re_eq = re.match(r'^(.+?)\s*=\s*(.+)$', assignment)
+        if re_eq:
+            path = re_eq.group(1)
+            if path in ignore:
+                continue  # skip to the next assignment
+            value = parse_value(re_eq.group(2), slave, path)
+            set_dotted(
+                slave, path, value, verbosity=verbosity, conflict_fail=False)
+        else:
+            raise FCParamsError("invalid assignment: '{}'".format(assignment))
+
+
+def generate_slave_id(game_vars):
+    """Returns the next available slave ID."""
+    # for compatibility, use the same technique as the Javascript code
+    all_slave_ids = []
+    for slist in ["slaves", "tanks", "cribs"]:
+        all_slave_ids += [slave["ID"] for slave in game_vars[slist]]
+    while game_vars["IDNumber"] in all_slave_ids:
+        game_vars["IDNumber"] += 1
+    # ugly way to do the same as "return V.IDNumber++;" in src/js/utilsFC.js
+    game_vars["IDNumber"] += 1
+    return game_vars["IDNumber"] - 1
+
+
+def generate_missing_parent_id(game_vars):
+    """Returns the next available negative slave ID (starting at -10000)."""
+    if game_vars["missingParentID"]:
+        game_vars["missingParentID"] -= 1
+    else:
+        game_vars["missingParentID"] = -10001
+    return game_vars["missingParentID"] + 1
+
+
+def clone_slave(game_vars, orig_slave, same_parents=False):
+    """Clone a slave `orig_slave` and give it a new ID."""
+    new_slave = copy.deepcopy(orig_slave)
+    new_slave["ID"] = generate_slave_id(game_vars)
+    for zero_key in [
+            "bodyswap", "clone", "cloneID", "cumSource", "daughters",
+            "milkSource", "origBodyOwnerID", "preg", "pregType", "pregSource",
+            "pregWeek", "readyOva", "recruiter", "relation", "relationTarget",
+            "relationship", "relationshipTarget", "rivalry", "rivalryTarget",
+            "sisters", "subTarget"
+    ]:
+        if zero_key in new_slave:
+            new_slave[zero_key] = 0
+    for counter in [
+            "PCChildrenFathered", "PCKnockedUp", "births", "birthsTotal",
+            "laborCount", "slavesFathered", "slavesKnockedUp"
+    ]:
+        if counter in new_slave["counter"]:
+            new_slave["counter"][counter] = 0
+    if "origBodyOwner" in new_slave:
+        new_slave["origBodyOwner"] = ""
+    if "womb" in new_slave:
+        new_slave["womb"] = []
+    if same_parents:
+        # Add a sister (mother and father already copied but may need changes).
+        if orig_slave["mother"] == -1:
+            game_vars["PC"]["daughters"] += 1
+        elif orig_slave["mother"] == 0 or orig_slave["mother"] == -2:
+            orig_slave["mother"] = generate_missing_parent_id(game_vars)
+            new_slave["mother"] = orig_slave["mother"]
+        if orig_slave["father"] == -1:
+            game_vars["PC"]["daughters"] += 1
+        elif orig_slave["father"] == 0 or orig_slave["father"] == -2:
+            orig_slave["father"] = generate_missing_parent_id(game_vars)
+            new_slave["father"] = orig_slave["father"]
+        # Adjust the counters of daughters and sisters.
+        for slave in game_vars["slaves"]:
+            if (slave["ID"] == new_slave["mother"] or
+                    slave["ID"] == new_slave["father"]):
+                slave["daughters"] += 1
+            if (slave["mother"] == new_slave["mother"] or
+                    slave["father"] == new_slave["father"] or
+                    slave["mother"] == new_slave["father"] or
+                    slave["father"] == new_slave["mother"]):
+                slave["sisters"] += 1
+                new_slave["sisters"] += 1
+    else:
+        # Clear the family info.
+        new_slave["sisters"] = 0
+        new_slave["mother"] = 0
+        new_slave["father"] = 0
+        new_slave["slaveSurname"] = 0
+        new_slave["birthSurname"] = 0
+    new_slave["slaveName"] = "Clone #{}".format(new_slave["ID"])
+    if new_slave["birthName"]:
+        # Append a number to the birth name, but check if it is not used yet.
+        base_name = new_slave["birthName"]
+        clone_num = 1
+        end_num = re.match(r'^(.+) (\d+)$', base_name)
+        if end_num:
+            base_name = end_num.group(1)
+            clone_num = int(end_num.group(2))
+        for name in [slave["birthName"] for slave in game_vars["slaves"]]:
+            end_num = re.match(rf'^{base_name} (\d+)$', name)
+            if end_num and int(end_num.group(1)) > clone_num:
+                clone_num = int(end_num.group(1))
+        clone_num += 1
+        new_slave["birthName"] = f"{base_name} {clone_num}"
+    new_slave["assignment"] = "rest"
+    if game_vars["JobIDArray"]:
+        if "rest" in game_vars["JobIDArray"]:
+            game_vars["JobIDArray"]["rest"].append(new_slave["ID"])
+        else:
+            game_vars["JobIDArray"]["rest"] = [new_slave["ID"]]
+    if game_vars["slaveIndices"]:
+        max_val = max(game_vars["slaveIndices"].values())
+        game_vars["slaveIndices"][str(new_slave["ID"])] = max_val + 1
+    game_vars["slaves"].append(new_slave)
+    return new_slave
+
+
+# Silence pylint here because many of the commands do not use their `options`.
+# pylint: disable=unused-argument
+
+
+def command_get(save_obj, target, options=None):
+    """Returns the part of `save_obj` that matches the path `target`
+
+    Command: -g --get VAR
+        Get JSON object VAR (shortcuts: V.*, slaves[N].*, PC.*).
+
+        The JSON object matching the dotted path `VAR` will be output.  The
+        following shortcuts are availble:
+
+        - V.* or $V.* replaces state.delta[0].variables.*
+
+        - slaves[N].* replaces state.delta[0].variables.slaves[N].*, where N
+        is the index number of the slave in the slaves array, starting at 0.
+
+        - PC.* or $PC.* replaces state.delta[0].variables.PC.*
+
+        This command should appear last on the command line, otherwise the
+        selected object(s) will be replaced by the next command.
+    """
+    path = top_path_aliases(target)
+    obj = get_dotted(save_obj, path)
+    return obj
+
+
+def command_set(save_obj, changes, options=None):
+    """Applies the assignment(s) in `changes` to `save_obj`.
+
+    Command: -s --set VAR=VAL[,VAR=VAL...]
+        Set JSON object VAR to value VAL.
+
+        Multiple assignments can be separated by commas, as long as they are
+        passed as a single parameter on the command line (using quotes as
+        necessary).
+
+        Note that each dotted path `VAR` is relative to the top level of the
+        game state.  The same shortcuts as for --get are available (V.*,
+        slaves[N].*, PC.*).
+
+        The value `VAR` can be any string or number, with special meanings
+        interpreted in the following order:
+
+        - If the value starts with "string:" then the following string will be
+        used as is, disabling the interpretation of special characters except
+        for the comma "," that always separates the assignments.
+
+        - One or more "|" characters can be used to split the value in a list
+        of sub-strings from which one will be picked at random.
+
+        - If the value starts with "atleast:", "atmost:", or "inrange:", then
+        the following string defines respectively the minimum, maximum, or
+        both (two integers separated by ":") for `VAR`.  The old value of
+        `VAR` will be compared with the limit(s) and will be changed only if
+        necessary.
+
+        - If the value contains two numbers (postive or negative) separated by
+        "~" (tilde), then the result will be picked at random between these
+        two numbers (inclusive).
+
+        - If the value looks like an integer or a floating-point number, then
+        the result will be converted to a number.  You can force a number to
+        be interpreted as a string by prefixing it with "string:".
+
+        - If the value is "null", "true" or "false", then it will be converted
+        to JSON null, true or false.  You can force a special value to be
+        interpreted as a string by prefixing it with "string:".
+
+        - If the value is surrounded by "{" and "}" or by "[" and "]", then it
+        will be interpreted as a JSON object or JSON list.
+        Note: due to a limitation of the implementation, the assignments are
+        split along the commas before the values are parsed, which means that
+        it is currently not possible to supply a JSON object or list
+        containing more than one element.  This can be considered as a bug.  A
+        workaround is to use --json instead of --set.
+
+        - If the value starts with "ref:", then it is interpreted as a
+        reference to another variable.  The shortcuts V.*, PC.* or
+        slave[N].* are allowed in the reference for the --set command but not
+        for --set-slaves.
+
+        The special meanings are interpreted in the order given above as the
+        value is read, which allows some complex combinations.  For example,
+        the value "10~20|ref:some.var|atleast:-5" means that the result will
+        be either an integer between 10 and 20, or a copy of the other
+        variable `some.var`, or will keep the current value as long as it is
+        greater or equal to the minimum -5.  In this example, the choice
+        delimiter "|" is interpreted first and one of the sub-strings is
+        selected at random, then the value of that sub-string is parsed
+        according to the next rules.
+    """
+    if options is not None and options.verbose:
+        verbosity = options.verbose
+    else:
+        verbosity = None
+    for assignment in re.split(r',\s*', changes):
+        try:
+            path, valexpr = assignment.split("=", 1)
+        except ValueError:
+            raise FCParamsError(
+                "argument to --set should be of the form dotted.name=value: " +
+                assignment)
+        path = top_path_aliases(path)
+        value = parse_value(valexpr, save_obj, path, ref_aliases=True)
+        save_obj = set_dotted(save_obj, path, value, verbosity=verbosity)
+    if changes:
+        save_obj = set_dotted(save_obj, "state.delta[0].variables.cheater", 1)
+    return save_obj
+
+
+def command_json(save_obj, jstring, options=None):
+    """Merges the JSON strong `jstring` into `save_obj`.
+
+    Command: -j --json JSON
+        Merge JSON string into JSON output.
+
+        A JSON object (as complex as necessary) will be merged with the
+        current state of the game.  As the merge is done from the top level,
+        the object should probably follow the structure
+        `{"state":{"delta":[{"variables":{...}}]}}` where "..." represents
+        the variable(s) to be modified.
+    """
+    return deepmerge(save_obj, json.loads(jstring))
+
+
+def _print_dotted(path, value, options):
+    """Callback for find key/value: prints the path, not the value."""
+    print(dotted_to_str(path))
+
+
+def _print_dotted_val(path, value, options):
+    """Callback for find key/value: prints the path and abbreviated value."""
+    if options.width:
+        width = options.width
+    else:
+        width = shutil.get_terminal_size().columns
+    path_str = dotted_to_str(path)
+    val_abbr = textwrap.shorten(
+        json.dumps(value, sort_keys=options.sort),
+        width=max(3, width - len(path_str)),
+        placeholder="...")
+    print(f"{path_str} = {val_abbr}")
+
+
+def _print_dotted_val_full(path, value, options):
+    """Callback for find key/value: prints the path and whole value."""
+    val_str = json.dumps(value, sort_keys=options.sort, indent=options.indent)
+    print(f"{dotted_to_str(path)} = {val_str}")
+
+
+def command_find_key(save_obj, key_name, options=None):
+    """Tries to find a key matching `key_name` in `save_obj`.
+
+    Command: --find-key NAME
+        Tries to find a VAR (key) that matches NAME.
+
+        The NAME to search for can be specified as a string with optional
+        shell wildcards ("*", "?") or as a regular expression surrounded by
+        "/.../".
+
+        For each key that matches NAME, the path to that key will be printed.
+        If the verbosity level is at least one (-v), then the value will also
+        be printed but limited to its first 30 characters.  If the verbosity
+        level is at least 2 (-vv), then the whole value will be printed
+        regardless of its length.
+
+        If this option appears last on the command line, the total number of
+        matches will be printed at the end.
+    """
+    re_match = re.match(r"^/(.+)/$", key_name)
+    if re_match:
+        regex_key = re.compile(re_match.group(1))
+    else:
+        regex_key = re.compile("^" + fnmatch.translate(key_name) + "$")
+    if options is None or options.verbose <= 0:
+        callback = _print_dotted
+    elif options.verbose <= 1:
+        callback = _print_dotted_val
+    else:
+        callback = _print_dotted_val_full
+    return deepfind(
+        save_obj, regex_key=regex_key, callback=callback, context=options)
+
+
+def command_find_value(save_obj, value_str, options=None):
+    """Tries to find a string containing `value_str` in `save_obj`.
+
+    Command: --find-value NAME
+        Tries to find a string value that matches NAME.
+
+        The NAME to search for can be specified as a string with optional
+        shell wildcards ("*", "?") or as a regular expression surrounded by
+        "/.../".
+
+        For each key that matches NAME, the path to that key will be printed.
+        If the verbosity level is at least one (-v), then the value will also
+        be printed but limited to its first 30 characters.  If the verbosity
+        level is at least 2 (-vv), then the whole value will be printed
+        regardless of its length.
+
+        If this option appears last on the command line, the total number of
+        matches will be printed at the end.
+    """
+    re_match = re.match(r"^/(.+)/$", value_str)
+    if re_match:
+        regex_val = re.compile(re_match.group(1))
+    else:
+        regex_val = re.compile("^" + fnmatch.translate(value_str) + "$")
+    if options is None or options.verbose <= 0:
+        callback = _print_dotted
+    elif options.verbose <= 1:
+        callback = _print_dotted_val
+    else:
+        callback = _print_dotted_val_full
+    return deepfind(
+        save_obj, regex_val=regex_val, callback=callback, context=options)
+
+
+def command_get_slaves(save_obj, selector, show_vars, options=None):
+    """Returns specific fields from the slaves matching `selector`.
+
+    Command: --get-slaves SELECTOR VAR[,VAR...]
+        Select the slaves matching SELECTOR and output the fields
+        named VAR plus those used for the selection, or "*" to output
+        all fields.
+
+        SELECTOR can be one of the following:
+
+        - "*", "all", or "All" to select all slaves.
+
+        - A condition or comma-separated list of conditions, using one of
+        the numerical comparison operators "<", "<=", ">", ">=", or a string
+        comparison "!=" or "==", or just a number matching a slave ID, or a
+        name matching a slave name, surname, birth name or birth surname
+        (shell wildcards are allowed).  Any slave that matches at least one
+        of the conditions will be selected.  For example,
+        "Miss A*, skill.oral<30" would select Miss Anne and any slave who has
+        less than 30 in the oral skill.
+
+        - In square brackets "[...]", a number or a comma-separated list of
+        numbers.  The numbers will be used as indexes in the slaves array,
+        starting at 0.  As the ordering of the slaves array is difficult to
+        predict, it is usually better to refer to the slaves by their ID
+        or by their name as described in the previous paragraph instead of
+        using their index in square brackets.
+
+        This command should appear last on the command line, otherwise the
+        selected object(s) will be replaced by the next command.
+
+        VAR,[,VAR...] can be one of the following:
+
+        - "" (empty), "*", "all", or "All" to select all fields for each slave
+
+        - A dotted path to a variable such as "eye.left" or "intelligence",
+        or a comma-separated list of them.
+
+        - One of the special aliases "name", "names", "parent", or "parents".
+        "name" or "names" will be replaced by "birthName", "birthSurname",
+        "slaveName", "slaveSurname".  "parent" or "parents" will be replaced
+        by "mother" and "father".
+    """
+    if selector is None:
+        raise RuntimeError("missing selector for --get-slaves command")
+    slaves = get_dotted(save_obj, "state.delta[0].variables.slaves")
+    show_fields = {"ID"}
+    selected = select_slaves(slaves, selector, show_fields=show_fields)
+    if show_vars and show_vars not in {"all", "All", "*"}:
+        for field in re.split(r',\s*', show_vars):
+            show_fields.add(field)
+        for field in FIELD_ALIASES:
+            if field in show_fields:
+                for new_field in FIELD_ALIASES[field]:
+                    show_fields.add(new_field)
+                show_fields.remove(field)
+        filtered = []
+        for slave in selected:
+            out = {}
+            for field in show_fields:
+                set_dotted(out, field, get_dotted(slave, field))
+            filtered.append(out)
+        return filtered
+    return selected
+
+
+def command_set_slaves(save_obj, selector, changes, options=None):
+    """Modifies the slaves matching `selector` according to `changes`.
+
+    Command: --set-slaves SELECTOR VAR=VAL[,VAR=VAL...]
+        Modify all slaves matching SELECTOR and apply the changes VAR=VAL.
+
+        If this command appears last on the command line and the output is
+        uncompressed JSON, then only the selected slaves will be output.
+        Using --set-slaves with an empty list of assignments ("") produces
+        the same results as --get-slaves with the same selector and an empty
+        list of selected fields or "*".
+
+        If the verbosity level is at least 1, then the list of changes will
+        be printed for each slave.  If the verbosity level is at least 2,
+        then the list of variables that are checked but not changed (because
+        their value is already acceptable) will also be printed.
+    """
+    if selector is None:
+        raise RuntimeError("missing selector for set_slaves command")
+    if changes is None:
+        raise RuntimeError("missing assignments for set_slaves command")
+    if options is not None and options.verbose:
+        verbosity = options.verbose
+    else:
+        verbosity = None
+    slaves = get_dotted(save_obj, "state.delta[0].variables.slaves")
+    selected_slaves = select_slaves(slaves, selector)
+    assignments = re.split(r',\s*', changes)
+    for slave in selected_slaves:
+        modify_slave(
+            slave, assignments, verbosity=verbosity, ignore=IGNORE_IN_SLAVES)
+    if changes:
+        save_obj = set_dotted(save_obj, "state.delta[0].variables.cheater", 1)
+    return selected_slaves
+
+
+def _command_copy_or_clone(save_obj,
+                           selector,
+                           changes,
+                           options=None,
+                           same_parents=False):
+    """Implements command_copy_slave or command_clone_slave."""
+    if selector is None:
+        raise RuntimeError("missing selector for copy_slaves command")
+    if changes is None:
+        raise RuntimeError("missing assignments for copy_slaves command")
+    if options is not None and options.verbose:
+        verbosity = options.verbose
+    else:
+        verbosity = None
+    game_vars = get_dotted(save_obj, "state.delta[0].variables")
+    selected_slaves = select_slaves(game_vars["slaves"], selector)
+    if not selected_slaves:
+        raise FCBadSelectorError("no slaves match the selector "
+                                 "'{}'".format(selector))
+    new_slave = clone_slave(
+        game_vars, selected_slaves[-1], same_parents=same_parents)
+    assignments = re.split(r',\s*', changes)
+    modify_slave(
+        new_slave, assignments, verbosity=verbosity, ignore=IGNORE_IN_SLAVES)
+    save_obj = set_dotted(save_obj, "state.delta[0].variables.cheater", 1)
+    return new_slave
+
+
+def command_copy_slave(save_obj, selector, changes, options=None):
+    """Returns a copy of the last slave matching `selector`.
+
+    Command: --copy-slave SELECTOR VAR=VAL[,VAR=VAL...]
+        Copy the last slave matching SELECTOR and apply the changes VAR=VAL.
+
+        A copy of the slave matching SELECTOR (or of the last match if
+        the selector matches multiple slaves) is created and then modified
+        according to the assignments VAR=VAL.  The new clone has no family.
+
+        The new clone is named "Clone #ID" where ID is its ID number.  You
+        can rename the new clone by including "slaveName=NNN" in the list
+        of assignments, where NNN is the new name of the clone.
+
+        The commands --copy-slave and --clone-slave are similar, but
+        --copy-slave erases the family relationships and family name of the
+        clone, so it forgets about its origins.
+    """
+    return _command_copy_or_clone(
+        save_obj, selector, changes, options=options, same_parents=False)
+
+
+def command_clone_slave(save_obj, selector, changes, options=None):
+    """Returns a clone of the last slave matching `selector`.
+
+    Command: --clone-slave SELECTOR VAR=VAL[,VAR=VAL...]
+        Clone the last slave matching SELECTOR and apply the changes VAR=VAL.
+
+        A clone of the slave matching SELECTOR (or of the last match if
+        the selector matches multiple slaves) is created and then modified
+        according to the assignments VAR=VAL.  The new clone is a twin sister
+        of the original slave.
+
+        The new clone is named "Clone #ID" where ID is its ID number.  You
+        can rename the new clone by including "slaveName=NNN" in the list
+        of assignments, where NNN is the new name of the clone.
+
+        The commands --copy-slave and --clone-slave are similar, but
+        --clone-slave creates the clone as a sister of the original slave,
+        keeping the same family name.  The number of daughters and sisters
+        of the other family members are updated accordingly.
+
+        The clone will have the same age as the original slave so it will be
+        her twin sister, but you can change that by assigining a different
+        age to the clone or using one of the shortcut actions "teen", "young",
+        or "mature".
+    """
+    return _command_copy_or_clone(
+        save_obj, selector, changes, options=options, same_parents=True)
+
+
+def command_get_pc(save_obj, show_vars, options=None):
+    """Returns specific fields from the player character.
+
+    Command: --get-pc VAR[,VAR...]
+        Output the fields of the PC named VAR, or "*" to output all fields.
+
+        This command should appear last on the command line, otherwise the
+        selected object(s) will be replaced by the next command.
+    """
+    player = get_dotted(save_obj, "state.delta[0].variables.PC")
+    show_fields = set()
+    if show_vars and show_vars not in {"all", "All", "*"}:
+        for field in re.split(r',\s*', show_vars):
+            show_fields.add(field)
+        for field in FIELD_ALIASES:
+            if field in show_fields:
+                for new_field in FIELD_ALIASES[field]:
+                    show_fields.add(new_field)
+                show_fields.remove(field)
+        out = {}
+        for field in show_fields:
+            set_dotted(out, field, get_dotted(player, field))
+        return out
+    return player
+
+
+def command_set_pc(save_obj, changes, options=None):
+    """Applies some `changes` to the player character.
+
+    Command: --set-pc VAR=VAL[,VAR=VAL...]
+        Modify the player character according to the changes VAR=VAL.
+
+        This is similar to --set-slaves, but for the player character.  The
+        same shortcut actions (see --list-actions) can be used for the PC, but
+        the ones modifying fields that do not exist in the PC will be silently
+        ignored.
+
+        If this command appears last on the command line and the output is
+        uncompressed JSON, then only the PC object will be output.
+    """
+    if changes is None:
+        raise RuntimeError("missing assignments for set_pc command")
+    if options is not None and options.verbose:
+        verbosity = options.verbose
+    else:
+        verbosity = None
+    player = get_dotted(save_obj, "state.delta[0].variables.PC")
+    assignments = re.split(r',\s*', changes)
+    modify_slave(player, assignments, verbosity=verbosity, ignore=IGNORE_IN_PC)
+    if changes:
+        save_obj = set_dotted(save_obj, "state.delta[0].variables.cheater", 1)
+    return player
+
+
+def command_top_up(save_obj, options=None):
+    """Tops up the cach and reputation.
+
+    Command: --top-up --topup
+        Maximize cash (100M) and reputation (20k).
+
+        This is a quick way to get a ridiculously high amount of money and
+        unlock all features of the arcology without having to click 1000 times
+        on "Add 100000 money" in cheat mode.  Of course you will be flagged as
+        a cheater if you use this command or any other command that modifies
+        the state of the game.
+    """
+    return command_set(
+        save_obj, "V.cash=100000000, V.rep=20000", options=options)
+
+
+# pylint: enable=unused-argument
+
+
+def list_actions(width=None, verbosity=0):
+    """Prints the list of shortcuts ("actions") for the assignments."""
+    maxkeylen = max([len(key) for key in SLAVE_ACTIONS])
+    if width:
+        if width < max(40, maxkeylen + 20):
+            raise FCParamsError("width {} must be at least "
+                                "{}".format(width, max(40, maxkeylen + 20)))
+    else:
+        width = shutil.get_terminal_size().columns - 2
+    wrapper = textwrap.TextWrapper(
+        width=width,
+        subsequent_indent=(" " * (maxkeylen + 2)),
+        break_long_words=False,
+        break_on_hyphens=False)
+    print("The following actions can replace an assignment VAR=VAL:")
+    if verbosity < 3:
+        print("(You can increase the verbosity level to see more details)")
+    if verbosity >= 1:
+        if verbosity >= 2:
+            actions = {}
+            for key in SLAVE_ACTIONS:
+                actions[key] = expand_action(SLAVE_ACTIONS, key)
+        else:
+            actions = SLAVE_ACTIONS
+        for key in sorted(actions):
+            print(
+                wrapper.fill("{:<{}} {}".format(key, maxkeylen, actions[key])))
+        if verbosity >= 3:
+            all_assignments = []
+            for assignments in actions.values():
+                all_assignments += assignments
+            print("\nThese actions include the following assignments:")
+            for assignment in sorted(set(all_assignments)):
+                print(wrapper.fill(assignment))
+    else:
+        for key in sorted(SLAVE_ACTIONS):
+            print(key)
+
+
+def read_game_file(infile, compressed=True):
+    """Reads and returns the optionally compressed JSON data from infile."""
+    if compressed:
+        b64_data = infile.read()
+        json_string = lzstring.LZString().decompressFromBase64(b64_data)
+    else:
+        json_string = infile.read()
+    parsed_json = json.loads(json_string)
+    return parsed_json
+
+
+def write_game_file(outfile, obj, compressed=True, indent=None, sort_keys=True):
+    """Writes obj as JSON string in outfile, with optional compression."""
+    if indent is not None and indent > 0:
+        separators = (',', ': ')
+    else:
+        separators = (',', ':')
+    json_string = json.dumps(
+        obj, indent=indent, sort_keys=sort_keys, separators=separators)
+    if compressed:
+        b64_data = lzstring.LZString().compressToBase64(json_string)
+        outfile.write(b64_data)
+    else:
+        outfile.write(json_string)
+        outfile.write("\n")
+
+
+def modify_file_name(orig_name, suffix="-cheat"):
+    """Modifies a file name to insert a suffix before the file extension."""
+    if orig_name is None or orig_name == "-" or orig_name == "<stdin>":
+        raise FCParamsError("the input is not a named file.  Cannot generate "
+                            "output file name")
+    # If the suffix is already present before the file extension, add a number.
+    re_fname = re.match(rf"^(.+{re.escape(suffix)})(\d+)(\..+?)$", orig_name)
+    if re_fname:
+        return "".join([
+            re_fname.group(1),
+            str(int(re_fname.group(2)) + 1),
+            re_fname.group(3)
+        ])
+    re_fname = re.match(rf"^(.+{re.escape(suffix)})(\..+?)$", orig_name)
+    if re_fname:
+        return "".join([re_fname.group(1), "2", re_fname.group(2)])
+    # If there is no suffix but there is a file extension, insert it before.
+    re_fname = re.match(r"^(.+)(\..+?)$", orig_name)
+    if re_fname:
+        return "".join([re_fname.group(1), suffix, re_fname.group(2)])
+    # If the suffix is already present without file exension, add a number.
+    re_fname = re.match(rf"^(.+{re.escape(suffix)})(\d+)$", orig_name)
+    if re_fname:
+        return "".join([re_fname.group(1), str(int(re_fname.group(2)) + 1)])
+    re_fname = re.match(rf"^(.+{re.escape(suffix)})$", orig_name)
+    if re_fname:
+        return "".join([re_fname.group(1), "2"])
+    # Else just append the suffix.
+    return orig_name + suffix
+
+
+def print_long_help(help_sections, width=None, verbosity=0):
+    """Displays the detailed help messages when --long-help is used."""
+
+    def print_title(line):
+        """Prints a line followed by a line of dashes of the same length."""
+        print(line)
+        print("-" * len(line))
+
+    long_help_intro_text = """
+    The commands can appear multiple times on the command line.  They
+    modify the game variables loaded from the input file.
+
+    VAR, VAL, VAR=VAL, and SELECTOR have the following meanings:
+
+    - VAR is a dotted path to an object, such as "arm.left.type" for a
+    slave or "state.delta[0].variables.slaves[0]" from the top level.
+
+    - VAL can be a string, a number, true, false, null, a set of random
+    choices separated by "|", a random number from a range A~B (using "~"),
+    a JSON object or array if it is surrounded by "{}" or "[]", a reference
+    to another variable prefixed with "ref:", or a string without any of
+    these special meanings if prefixed with "string:".  See --set for
+    details.
+
+    - SELECTOR selects one or more slaves by id (number or comma-separated
+    list of numbers), by condition ("intelligence>50" or "genes=XY"), or by
+    name or comma-separated list of names with wildcards (matching name,
+    surname, birth name, or birth surname).  See --get-slaves for details.
+    "*" or "all" selects all slaves.
+
+    - Assignments VAR=VAL can be repeated, separated by commas.  They can
+    also be replaced by actions that are shortcuts to a list of assignments.
+    Use --list-actions to see the list of shortcuts and their expansion.
+
+    The commands --get, --get-slaves and --get-pc are only useful if they
+    appear last on the command line and if the output is not compressed.
+    In that case, only the selected objects will be output in JSON format.
+    Otherwise the output will include the whole game state.
+    """
+    long_examples = [
+        """{prog} -i free-cities-123456.save -p -O free-cities-123456.json
+
+        Loads the file "free-cities-123456.save" (-i ...) and saves its
+        contents as a JSON file (-O ...) with nice indentation (-p).  You can
+        then use any JSON processing tool to analyze the contents of this new
+        file or modify it.  Without the arguments
+        "-O free-cities-123456.json", this command would send the formatted
+        JSON to standard output where it could be directly piped to other
+        commands such as "grep".
+        """,
+        """{prog} -I free-cities-123456.json -o free-cities-123456.save
+
+        Loads the JSON file "free-cities-123456.json" (-I ...) and saves it as
+        a compressed file "free-cities-123456.save" (-o ...).
+        """,
+        """{prog} -i fc-123456.save -p --get-slaves all name,career,assignment
+
+        Selects all slaves from the file "fc-123456.save" and prints their
+        names and surnames, as well as their past career and current
+        assignment.
+        """,
+        """{prog} -i fc-123456.save -A --clone-slave "Miss Lily" "Nurse,slaveName=Doctor Diana,randomhair"
+
+        Creates a twin sister of the slave called "Miss Lily" and modifies the
+        new clone to be an ideal nurse (which makes her your genius, beautiful
+        wife with a dom fetish and career experience as a nurse).  Sets her
+        slave name to be "Doctor Diana" and randomizes her hairstyle and
+        color.  Saves the results in a file called "fc-123456-cheat.save"
+        (option -A with the default suffix "-cheat").
+        """,
+        """{prog} -i fc-123456.save -A --set-slaves "intelligence>50" nympho,bimbo
+
+        Selects all your smart slaves (intelligence>50) and turns them into
+        dumb bimbos with fake boobs and fake butt who are addicted to sex.
+        Saves the results in a file called fc-123456-cheat.save.
+        """,
+        """{prog} -i fc-123456.save --set-slaves all healthy,boobs=atleast:900 --no-output -v
+
+        Selects all slaves and displays what changes would be applied to make
+        them healthy with boobs that are at least DD-cup.  A summary of the
+        changes is displayed (-v) but they are not saved to a file nor shown
+        on standard output (--no-output).  With an additional verbosity level
+        (-vv), this command would also display a detailed list of variables
+        that are checked but not changed if their value is already in the
+        acceptable range.
+        """,
+        """{prog} -i fc-123456.save --suffix=-godmode -A --top-up --set-slaves all perfect,experienced
+
+        Maximizes your money and reputation (--top-up), then modifies all
+        slaves to be healthy geniuses with goddess bodies, perfect skills and
+        experience.  Saves the results in a new file called
+        "fc-123456-godmode.save" (option -A with a custom --suffix).
+        Playing the game would be rather pointless with that extreme cheating
+        but this can be useful for testing some new features.
+        """,
+        """{prog} --list-actions -v
+
+        Lists the shortcuts (actions) for the assignments that can be used
+        with the commands --set-slaves, --copy-slave, --clone-slave, or
+        --set-pc.  With verbosity level 1 (-v), this command shows the
+        lists of assignments or other actions associated with each action.
+        With verbosity level 2 (-vv), these lists would be expanded
+        recursively.  With the default verbosity (no -v), only the names of
+        the actions would be listed.
+        """
+    ]
+    if width:
+        minwidth = 30
+        if width < minwidth:
+            raise FCParamsError(f"width {width} must be at least {minwidth}")
+    else:
+        width = shutil.get_terminal_size().columns - 2
+    print_title("Specifying commands")
+    help_wrapper = ParagraphWrapper(width=width, indent=0)
+    print(help_wrapper.format_paragraphs(long_help_intro_text))
+    print("\n")
+    print_title("List of available commands")
+    indent_wrapper = ParagraphWrapper(width=width, indent=8)
+    for help_section in help_sections:
+        (first_line, _, other_lines) = help_section.partition("\n")
+        print(help_wrapper.format_paragraphs(first_line))
+        print(indent_wrapper.format_paragraphs(other_lines))
+        print("")
+    print_title("Examples")
+    for example in long_examples:
+        (first_line, _,
+         other_lines) = example.replace("{prog}", sys.argv[0]).partition("\n")
+        print(help_wrapper.format_paragraphs(first_line))
+        print(indent_wrapper.format_paragraphs(other_lines))
+        print("")
+
+
+def main():
+    """If you run this program from the command line, you will end up here..."""
+
+    parser = argparse.ArgumentParser(
+        description="View or modify Free Cities save files, compressed or not.",
+        fromfile_prefix_chars="@",
+        epilog="These commands modify the game variables and can appear"
+        " multiple times on the command line.  The commands --get,"
+        " --get-slaves and --get-pc are only useful if they appear last on the"
+        " command line and if the output is not compressed.  See --long-help"
+        " for details.")
+    parser.add_argument(
+        "-H",
+        "--long-help",
+        action="store_true",
+        help="More detailed help and examples.")
+    parser.add_argument(
+        "-V",
+        "--version",
+        action="version",
+        version="%(prog)s " + __version__,
+        help="Show this program's version number and exit.")
+    infile_group = parser.add_mutually_exclusive_group()
+    infile_group.add_argument(
+        "-i",
+        "--infile",
+        "--input",
+        type=argparse.FileType("r"),
+        help="Name of the compressed input file.")
+    infile_group.add_argument(
+        "-I",
+        "--injson",
+        "--input-json",
+        type=argparse.FileType("r"),
+        default=sys.stdin,
+        help="Name of the JSON input file (default: stdin).")
+    outfile_group = parser.add_mutually_exclusive_group()
+    outfile_group.add_argument(
+        "-o",
+        "--outfile",
+        "--output",
+        type=argparse.FileType("w"),
+        help="Name of the compressed output file.")
+    outfile_group.add_argument(
+        "-O",
+        "--outjson",
+        "--output-json",
+        type=argparse.FileType("w"),
+        default=sys.stdout,
+        help="Name of the JSON output file (default: stdout).")
+    outfile_group.add_argument(
+        "-A",
+        "--output-auto",
+        action="store_true",
+        help="Generate the output file name from the input file name by adding"
+        " a suffix to it (see --suffix).  The output will be compressed or"
+        " not, depending on the type of the input.")
+    outfile_group.add_argument(
+        "-n",
+        "--no-output",
+        action="store_true",
+        help="Do not output the JSON data.  Can be used with an increased"
+        " verbosity level to see what changes are applied without saving them.")
+    parser.add_argument(
+        "-a",
+        "--output-all",
+        action="store_true",
+        help="When the output is uncompressed JSON, output the whole game state"
+        " even if only a part of it is selected by the last command.")
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        action="count",
+        default=0,
+        help="Verbosity level when modifying data or for --list-actions.  Can"
+        " be repeated to be more verbose (default: 0).")
+    parser.add_argument(
+        "--width",
+        action="store",
+        type=int,
+        help="Width of the output for --list-actions (default is term width).")
+    parser.add_argument(
+        "--suffix",
+        action="store",
+        type=str,
+        default="-cheat",
+        help="Suffix added to the file name by --output-auto (default:"
+        " '-cheat').  Can be set to an empty string if you want to overwrite"
+        " the input file.  Note: if the suffix starts with a dash, write this"
+        " argument as '--suffix=-mysuffix' and not '--suffix -mysuffix'.")
+    parser.add_argument(
+        "--indent",
+        action="store",
+        type=int,
+        help="Indent the JSON output by that many spaces per level.")
+    parser.add_argument(
+        "--sort", action="store_true", help="Sort the JSON keys.")
+    parser.add_argument(
+        "-p",
+        "--pretty-print",
+        action="store_true",
+        help="Same as --indent 2 --sort.")
+    parser.add_argument(
+        "--list-actions",
+        action="store_true",
+        help="Print the list of known actions (shortcuts) and exit.  With"
+        " increased verbosity levels you can see the list of actions, their"
+        " definitions, the fully expanded definitions, and the list of"
+        " assignments.")
+    parser.add_argument(
+        "--no-taint", action="store_true", help=argparse.SUPPRESS)
+    cmd_arg_group = parser.add_argument_group("optional, repeatable commands")
+    cmd_group = CommandGroupContainer(cmd_arg_group, dest="commands")
+    # Register all functions that declare arguments in their docstring
+    cmd_group.command_from_docstring(command_get)
+    cmd_group.command_from_docstring(command_set)
+    cmd_group.command_from_docstring(command_json)
+    cmd_group.command_from_docstring(command_find_key)
+    cmd_group.command_from_docstring(command_find_value)
+    cmd_group.command_from_docstring(command_get_slaves)
+    cmd_group.command_from_docstring(command_set_slaves)
+    cmd_group.command_from_docstring(command_copy_slave)
+    cmd_group.command_from_docstring(command_clone_slave)
+    cmd_group.command_from_docstring(command_get_pc)
+    cmd_group.command_from_docstring(command_set_pc)
+    cmd_group.command_from_docstring(command_top_up)
+    # And now try to make sense of all this
+    try:
+        args = parser.parse_args()
+
+        if args.long_help:
+            print_long_help(
+                cmd_group.format_long_help(),
+                width=args.width,
+                verbosity=args.verbose)
+            exit(0)
+        if args.list_actions:
+            list_actions(width=args.width, verbosity=args.verbose)
+            exit(0)
+        if args.pretty_print:
+            args.indent = 2
+            args.sort = True
+        if args.output_auto:
+            if args.infile is not None:
+                outname = modify_file_name(args.infile.name, suffix=args.suffix)
+                args.outfile = open(outname, "w")
+            else:
+                outname = modify_file_name(args.injson.name, suffix=args.suffix)
+                args.outjson = open(outname, "w")
+    except FCBaseError as error:
+        print(f"Error in command-line arguments: {error}.")
+        exit(2)
+
+    if args.infile is not None:
+        parsed_json = read_game_file(args.infile, compressed=True)
+    else:
+        parsed_json = read_game_file(args.injson, compressed=False)
+
+    # Execute all commands that were passed on the command line
+    if args.commands:
+        for func, fargs in args.commands:
+            try:
+                result_obj = func(parsed_json, *fargs, options=args)
+            except FCBaseError as error:
+                fname = func.__name__.replace("command_", "").replace("_", "-")
+                if isinstance(error, FCParamsError) and fargs:
+                    print("Error in {}: {} in arguments "
+                          "{}".format(fname, str(error), fargs))
+                else:
+                    print("Error in {}: {}".format(fname, str(error)))
+                exit(2)
+    else:
+        result_obj = parsed_json
+
+    # Unless the --no-taint option has been used, add a variable that marks
+    # the save file as tainted.  This should help when triaging bug reports:
+    # if a tainted save file appears in a bug report, then the user could be
+    # asked to try and reproduce the bug without hacking the state of the
+    # game.
+    if not args.no_taint:
+        set_dotted(parsed_json, "state.delta[0].variables.taintedSaveFile",
+                   "{} v{}".format(os.path.basename(sys.argv[0]), __version__))
+
+    if args.no_output:
+        pass
+    elif args.outfile is not None:
+        write_game_file(
+            args.outfile,
+            parsed_json,
+            compressed=True,
+            indent=args.indent,
+            sort_keys=args.sort)
+    elif args.output_all:
+        write_game_file(
+            args.outjson,
+            parsed_json,
+            compressed=False,
+            indent=args.indent,
+            sort_keys=args.sort)
+    else:
+        write_game_file(
+            args.outjson,
+            result_obj,
+            compressed=False,
+            indent=args.indent,
+            sort_keys=args.sort)
+
+
+if __name__ == '__main__':
+    main()
-- 
GitLab