space-haven/sheditor.py

766 lines
30 KiB
Python

import argparse
import copy
import os
import datetime
import csv
from bs4 import BeautifulSoup
import rich.console
DEFAULT_SAVEGAMEPATH = "c:\\Program Files (x86)\\GOG Galaxy\\Games\\SpaceHaven\\savegames\\"
# Only "normal storage" is used to insert requested items
normal_storage_ids = [82, 632]
weapon_ids = [726, 725, 3383, 760, 3069, 3070, 3071, 3072, 3384]
storage_ids = {
82: {"name": "Small storage", "capacity": 50},
632: {"name": "Large storage", "capacity": 250},
3062: {"name": "Body storage", "capacity": 20},
3068: {"name": "Robot storage", "capacity": 20},
}
console = rich.console.Console(highlight=False)
# A single item with its code, name, and quantity
class Item:
item_ids = None
@classmethod
def load_ids(cls, item_filename):
if cls.item_ids is not None:
return
else:
cls.item_ids = {}
with open(item_filename, 'r') as item_file:
item_reader = csv.reader(item_file, delimiter='\t')
for row in item_reader:
cls.item_ids[int(row[0])] = row[1].strip()
@classmethod
def get_name_from_code(cls, code):
return cls.item_ids.get(code, None) if cls.item_ids is not None else None
@classmethod
def validate_code(cls, code):
return code in cls.id_dict if cls.item_ids is not None else False
def __init__(self, code, name, quantity):
self.code = code
self.name = name
self.quantity = quantity
class ResearchItem:
research_ids = None
@classmethod
def load_ids(cls, item_filename):
if cls.research_ids is not None:
return
else:
cls.research_ids = {}
with open(item_filename, 'r') as item_file:
item_reader = csv.reader(item_file, delimiter='\t')
for row in item_reader:
# console.print(row)
if len(row) == 0 or row[0][0] == '#':
continue
cls.research_ids[int(row[0])] = row[1].strip()
@classmethod
def get_name_from_code(cls, code):
return cls.research_ids.get(code, None) if cls.research_ids is not None else None
def __init__(self, id, active_stage, paused, states):
self.id = id
self.name = ResearchItem.get_name_from_code(id)
self.paused = paused
self.active_stage = active_stage
self.stage_states = states
def __str__(self):
return "{} : {} : {}".format(self.id, self.name, self.active_stage)
class ResearchItemState:
def __init__(self, done, stage, blocksdone):
self.done = done
self.stage = int(stage)
self.blocksdone = blocksdone
def __str__(self):
return " {} : {} : {}".format(self.done, self.stage, self.blocksdone)
# A single storage area, with a list of contents (Items)
class StorageArea:
def __init__(self, tag):
self.tag = tag
self.type_id = None
self.items = []
self.is_normal_storage = False
def add_item(self, item):
self.items.append(item)
# Returns how full the storage area is based on its contents
def get_total_occupancy(self):
total_quantity = 0
for item in self.items:
total_quantity += item.quantity
return total_quantity
class Character:
skill_ids = None
attribute_ids = None
trait_ids = None
condition_ids = None
@classmethod
def load_ids(cls, skill_filename, attribute_filename, trait_filename, condition_filename):
if cls.skill_ids is None:
cls.skill_ids = {}
with open(skill_filename, 'r') as skill_file:
skill_reader = csv.reader(skill_file, delimiter='\t')
for row in skill_reader:
cls.skill_ids[int(row[0])] = row[1].strip()
if cls.attribute_ids is None:
cls.attribute_ids = {}
with open(attribute_filename, 'r') as attribute_file:
attribute_reader = csv.reader(attribute_file, delimiter='\t')
for row in attribute_reader:
cls.attribute_ids[int(row[0])] = row[1].strip()
if cls.trait_ids is None:
cls.trait_ids = {}
with open(trait_filename, 'r') as trait_file:
trait_reader = csv.reader(trait_file, delimiter='\t')
for row in trait_reader:
cls.trait_ids[int(row[0])] = row[1].strip()
if cls.condition_ids is None:
cls.condition_ids = {}
with open(condition_filename, 'r') as condition_file:
condition_reader = csv.reader(condition_file, delimiter='\t')
for row in condition_reader:
cls.condition_ids[int(row[0])] = row[1].strip()
@classmethod
def get_skill_from_code(cls, code):
return cls.skill_ids.get(code, None) if cls.skill_ids is not None else None
@classmethod
def get_attribute_from_code(cls, code):
return cls.attribute_ids.get(code, None) if cls.attribute_ids is not None else None
@classmethod
def get_trait_from_code(cls, code):
return cls.trait_ids.get(code, None) if cls.trait_ids is not None else None
@classmethod
def get_condition_from_code(cls, code):
return cls.condition_ids.get(code, None) if cls.condition_ids is not None else None
def __init__(self, name, tag):
self.name = name
self.tag = tag
self.is_clone = False
self.skills = []
self.attributes = []
self.conditions = []
def set_skills(self, skill_list):
# Expected format is a list of dictionaries
# Each dictionary has keys id, mxn, exp, expd, level
self.skills = skill_list
def set_attributes(self, attribute_list):
# Expected format is a list of dictionaries
# Each dictionary has keys id, points
self.attributes = attribute_list
def maximize_skills(self):
# Max LEVEL is 5
# Max MXN should be 8 for some reason
# Exp* keys are probably for a future experience system but I don't know how they work so I won't touch them
for skill in self.skills:
skill['level'] = 7
skill['mxn'] = 8
def maximize_attributes(self):
# Max POINTS is 6
for attribute in self.attributes:
attribute['points'] = 6
def clear_conditions(self):
self.conditions = [1550, 2246, 3311]
def clone(self, new_name):
# How to deep copy the skills/attributes appropriately?
print("Cloning character {}".format(self.name))
new_char = Character(new_name, copy.copy(self.tag))
new_char.is_clone = True
new_char.tag['name'] = new_name
new_char.skills = [s.copy() for s in self.skills]
new_char.attributes = [a.copy() for a in self.attributes]
return new_char
def print_summary(self):
console.print("Name: [bright_cyan]{}[/]".format(self.name))
console.print()
console.print("Skills:")
for i, skill in enumerate(self.skills):
row_string = "{:12} [bright_cyan]{:2}[/]".format(Character.get_skill_from_code(skill['id']), skill['level'])
if i % 2 == 0:
console.print(" [on bright_black]{}[/]".format(row_string))
else:
console.print(" {}".format(row_string))
console.print()
console.print("Attributes:")
for i, attribute in enumerate(self.attributes):
row_string = "{:12} [bright_cyan]{:2}[/]".format(Character.get_attribute_from_code(attribute['id']), attribute['points'])
if i % 2 == 0:
console.print(" [on bright_black]{}[/]".format(row_string))
else:
console.print(" {}".format(row_string))
def __repr__(self):
return "{} - Skills: {} - Attributes: {}".format(self.name, repr(self.skills), repr(self.attributes))
class Ship:
def __init__(self, name, owner, state, tag):
self.name = name
self.owner = owner
self.state = state
self.tag = tag # Soup tag corresponding to this ship's node
self.storage_areas = []
self.characters = []
def add_storage_area(self, storage_area):
self.storage_areas.append(storage_area)
def add_character(self, character):
self.characters.append(character)
def add_item(self, item):
normal_storage_areas = [sa for sa in self.storage_areas if sa.is_normal_storage]
min_storage_area = normal_storage_areas[0]
min_occupancy = min_storage_area.get_total_occupancy()
for storage_area in normal_storage_areas:
if storage_area.get_total_occupancy() < min_occupancy:
min_storage_area = storage_area
min_occupancy = min_storage_area.get_total_occupancy()
min_storage_area.add_item(item)
def redistribute_storage(self):
normal_storage_areas = [sa for sa in self.storage_areas if sa.is_normal_storage]
total = 0
area_capacity = 0
for area in normal_storage_areas:
total += area.get_total_occupancy()
area_capacity += storage_ids[area.type_id]['capacity']
console.print("Total used: {}".format(total))
console.print("Total actual capacity: {}".format(area_capacity))
def consolidate_weapons(self):
normal_storage_areas = [sa for sa in self.storage_areas if sa.is_normal_storage]
for area in normal_storage_areas:
for item in area.items:
if item.code in weapon_ids:
console.print("{} : {}".format(item.code, item.quantity))
class Player:
def __init__(self, currency, tag):
self.currency = currency
self.tag = tag
class GameData:
def __init__(self, soup, item_database):
self.player = None
self.ships = []
self.research = []
self.soup = soup
self.item_database = item_database
def populate(self):
# Step 1 - Player data
char_tag = self.soup.find('playerBank')
currency = int(char_tag['ca'])
self.player = Player(currency, char_tag)
# Step 2 - Ship data
ship_tags = self.soup.find_all('ship')
for ship_tag in ship_tags:
# Something strange is happening here
# In some unexplored ships/derelicts (?fog="true" in the tag) the name appears in the game but NOWHERE in the file
ship_name = "UNNAMED (?fogged)"
if ship_tag.has_attr('sname'):
ship_name = ship_tag['sname']
# console.print(ship_name)
# Forgotten - why did I specify owner as required here - what ships don't have owners?
owner_node = ship_tag.find('settings', owner=True)
ship_owner = owner_node['owner']
ship_state = owner_node['state']
ship = Ship(ship_name, ship_owner, ship_state, ship_tag)
self.ships.append(ship)
# Step 3 - Storage area data
for inv_tag in ship_tag.find_all('feat', eatAllowed=True):
storage_area = StorageArea(inv_tag.find('inv'))
# 632 is the "m" id code for LARGE storage
storage_area.type_id = int(inv_tag.parent.parent['m'])
storage_area.is_normal_storage = storage_area.type_id in normal_storage_ids
ship.add_storage_area(storage_area)
# Items within storage area
for s_tag in inv_tag.find_all('s'):
item_code = int(s_tag['elementaryId'])
item_name = Item.get_name_from_code(item_code)
item_quantity = int(s_tag['inStorage'])
item = Item(item_code, item_name, item_quantity)
storage_area.add_item(item)
# console.print("{:4}: {} - {}".format(item_code, item_name, item_quantity))
# Step 4 - Character data
for character_list in ship_tag.find_all('characters'):
character_tags = character_list.find_all('c', attrs={'name': True})
for character_tag in character_tags:
char_name = character_tag['name']
character = Character(char_name, character_tag)
ship.add_character(character)
skills = []
skill_tag = character_tag.find('skills')
for sk_tag in skill_tag.find_all('s'):
skill_id = sk_tag['sk']
skill_level = sk_tag['level']
skill_mxn = sk_tag['mxn']
skill_exp = sk_tag['exp']
skill_expd = sk_tag['expd']
skill_dict = {
'id': int(skill_id),
'level': int(skill_level),
'mxn': int(skill_mxn),
'exp': int(skill_exp),
'expd': int(skill_expd),
}
skills.append(skill_dict)
character.set_skills(skills)
attributes = []
attribute_tag = character_tag.find('attr')
for a_tag in attribute_tag.find_all('a'):
attribute_id = a_tag['id']
attribute_points = a_tag['points']
attribute_dict = {
'id': int(attribute_id),
'points': int(attribute_points),
}
attributes.append(attribute_dict)
character.set_attributes(attributes)
# Step 5 - Research Data
# console.print('Finding research...')
res_tag = self.soup.find("research", treeId=True)
item_ids = []
for item in res_tag.find_all('l', techId=True):
active_stage = int(item['activeStageIndex'])
paused = item['paused']
techId = int(item['techId'])
item_ids.append(int(item['techId']))
stages = []
for stage in item.find_all('l', done=True, stage=True):
done = stage['done']
stage_no = stage['stage']
# console.print(stage)
blocksdone_tag = stage.find('blocksDone')
blocksdone = None
if blocksdone_tag is not None:
blocksdone = [int(blocksdone_tag['level1']), int(blocksdone_tag['level2']), int(blocksdone_tag['level3'])]
ris = ResearchItemState(done, stage_no, blocksdone)
stages.append(ris)
ri = ResearchItem(techId, active_stage, paused, stages)
self.research.append(ri)
"""
# Research data
console.print('Research stage counts:')
res_tag = self.soup.find('research', treeId=True)
for category in res_tag.find_all('l', techId=True):
console.print("{} : {}".format(category['techId'], len(category.find_all('l', done=True, stage=True))))
"""
def writeback(self):
def replace_id(dict, old_key, new_key):
dict_copy = dict.copy()
dict_copy[new_key] = dict_copy[old_key]
del dict_copy[old_key]
return dict_copy
# Purpose of this mission is to take all our data and replace the relevant parts of the soup
# Suspect this may be harder than it sounds - original saved game editor more or less deleted and rewrote some sections (e.g. item lists)
# Shouldn't need to update player tag as this will be done directly
# Only need to update things with structure / lists
"""
Step 1 - Update characters
Step 2 - Update storage areas
"""
# Step 1 - Update characters
# Names etc will be done automatically - just need to reconstruct skills and attributes
self.player.tag['ca'] = self.player.currency
for ship in self.ships:
# console.print('Doing chars...')
for character in ship.characters:
# Cloned character tags have to be added to the list
if character.is_clone:
print("ADDING CLONED CHARACTER")
charlist_tag = ship.tag.find('characters')
charlist_tag.append(character.tag)
skill_tag = character.tag.find('skills')
skill_tag.clear()
for skill in character.skills:
skill_copy = replace_id(skill, 'id', 'sk')
new_tag = self.soup.new_tag('s', attrs=skill_copy)
skill_tag.append(new_tag)
attribute_tag = character.tag.find('attr')
attribute_tag.clear()
for attribute in character.attributes:
new_tag = self.soup.new_tag('a', attrs=attribute)
attribute_tag.append(new_tag)
condition_tag = character.tag.find('conditions')
condition_tag.clear()
for condition in character.conditions:
attrs = { "id": str(condition), "level" : "1", "rs" : "1"}
new_tag = self.soup.new_tag('c', attrs=attrs)
# What do the <rec> tags mean with ht and wt attrs?
# What does the ac attr mean on the m mood sub tag mean?
new_mood_tag = self.soup.new_tag('mood')
new_submood_tag = self.soup.new_tag('m', ac="5")
new_tag.append(new_mood_tag)
new_mood_tag.append(new_submood_tag)
condition_tag.append(new_tag)
for storage_area in ship.storage_areas:
area_tag = storage_area.tag
area_tag.clear()
for item in storage_area.items:
tag_dict = {'elementaryId': item.code, 'inStorage': item.quantity, 'onTheWayIn': 0, 'onTheWayOut': 0}
new_tag = self.soup.new_tag('s', attrs=tag_dict)
area_tag.append(new_tag)
for research_item in self.research:
pass
def add_item(self, item_code, item_quantity):
item_name = self.item_database.get_name_from_code(item_code)
item = Item(item_code, item_name, item_quantity)
for ship in self.ships:
if ship.owner == "Player":
ship.add_item(item)
break
def buff_characters(self):
for ship in self.ships:
if ship.owner == "Player":
for character in ship.characters:
character.maximize_skills()
character.maximize_attributes()
character.clear_conditions()
def add_currency(self, amount):
self.player.currency += amount
def clone_character(self, character_name, new_name):
console.print("Clone char method called")
console.print("Warning names are case sensitive!")
for ship in self.ships:
for character in ship.characters:
if character.name == character_name:
print("Found char to clone")
new_char = character.clone(new_name)
ship.characters.append(new_char)
return
def print_detailed_character_summary(self):
for ship in self.ships:
console.print("Listing characters for ship [magenta]{}[/] (owned by [bright_cyan]{}[/], state [bright_cyan]{}[/]):".format(ship.name, ship.owner, ship.state))
if len(ship.characters) == 0:
console.print(" This ship has no characters, skipping...")
console.print()
console.print()
continue
for character in ship.characters:
console.print()
character.print_summary()
console.print()
console.print('-----')
console.print()
def print_detailed_item_summary(self):
for ship in self.ships:
rich.print("Inventory for ship [#aa00ff]{}[/] (owned by [bright_cyan]{}[/], state [bright_cyan]{}[/]):".format(ship.name, ship.owner, ship.state))
if len(ship.storage_areas) == 0:
console.print(" This ship has no storage areas, skipping...")
console.print()
console.print()
continue
for index, storage_area in enumerate(ship.storage_areas):
console.print(" Storage area [bright_cyan]{}[/] (type: [bright_cyan]{}[/], normal storage: [bright_cyan]{}[/]):".format(index, storage_ids[storage_area.type_id]["name"], storage_area.is_normal_storage))
if len(storage_area.items) == 0:
console.print(" This storage area is empty.")
console.print()
continue
console.print("[bold] {:>4} {:30} {:>3}[/]".format("ID", "Name", "#"))
total_quantity = 0
for i, item in enumerate(storage_area.items):
row_string = " [bright_cyan]{:4}[/] {:30} [bright_cyan]{:3}[/]".format(item.code, item.name, item.quantity)
total_quantity += item.quantity
if i % 2 == 0:
console.print(" [on bright_black]{}[/]".format(row_string))
else:
console.print(" {}".format(row_string))
storage_area_capacity = storage_ids[storage_area.type_id]["capacity"]
console.print(" Total quantity: [bright_cyan]{}[/]/[bright_cyan]{}[/]".format(total_quantity, storage_area_capacity))
if total_quantity > storage_area_capacity:
console.print(" [bright_red]WARNING: Storage capacity exceeded[/]")
console.print()
console.print()
def print_summary(self):
console.print("Start game summary:")
console.print(" Player currency: {}".format(self.player.currency))
console.print(" Number of ships: {}".format(len(self.ships)))
for ship in self.ships:
console.print(" {} (owner {}, state {})".format(ship.name, ship.owner, ship.state))
console.print(" Contains {} storage area(s):".format(len(ship.storage_areas)))
for index, storage_area in enumerate(ship.storage_areas):
console.print(" Storage area {} - contains {} item(s) - occupancy {} unit(s)".format(index, len(storage_area.items), storage_area.get_total_occupancy()))
console.print(" Has {} character(s):".format(len(ship.characters)))
for char in ship.characters:
console.print(" {}".format(char.name))
# char.print_summary()
def redistribute(self):
for ship in self.ships:
if ship.owner != 'Player':
continue
console.print(ship.name)
ship.redistribute_storage()
def list_research(self):
for research in self.research:
console.print(research)
for ris in research.stage_states:
console.print(ris)
def consolidate_weapons(self):
for ship in self.ships:
if ship.owner == "Player":
ship.consolidate_weapons()
def parse_item_file(filename):
results = []
for line in open(filename):
line = line.strip()
if line[0] == '#':
continue
components = line.split()
item_code = int(components[0])
item_quantity = int(components[1])
results.append((item_code, item_quantity))
return results
def main():
parser = argparse.ArgumentParser(prog="Space Haven Saved Game Inspector", description="As above.")
parser.add_argument('filename', metavar='SAVEGAME_GAME_FILE')
parser.add_argument('--add-item', required=False, metavar=('ITEM_CODE', 'ITEM_QUANTITY'), type=int, nargs=2, help="Add more of an existing item to storage by CODE - refer to accompanying data file reference for codes. First number is the code, second is the desired quantity.")
parser.add_argument('--buff-chars', required=False, action='store_true', help="For all characters, increases all skills and attributes to maximum. Use wisely.")
parser.add_argument('--money', required=False, type=int, nargs=1, metavar='AMOUNT', help="Give the player credits of the specified amount")
parser.add_argument('--list-ships', required=False, action='store_true', help="List all ships with names and their respective owners")
parser.add_argument('--test-gamedata', required=False, action='store_true', help="Test of new class-based system of storing game information")
parser.add_argument('--clone-character', required=False, type=str, nargs=2, metavar=('OLD_NAME', 'NEW_NAME'), help="Clones a character of one name into another")
parser.add_argument('--add-item-set', required=False, type=str, nargs=1, metavar='PACK_FILENAME', help="Takes a file containing a list of item codes and quantities (whitespace separated) and adds all of these to player storage")
parser.add_argument('--detailed-items', required=False, action='store_true', help='Print a detailed item listing from player inventory')
parser.add_argument('--detailed-chars', required=False, action='store_true', help='Print a comprehensive listing of player character details')
parser.add_argument('--redistribute', required=False, action='store_true', help='Redistribute internal storage')
parser.add_argument('--list-research', required=False, action='store_true', help='List all research/research states')
parser.add_argument('--consolidate-weapons', required=False, action='store_true', help='Put all weapons in one storage area')
parser.add_argument('--replace-original', required=False, action='store_true', help='Replace original file instead of creating edited alternative. Renames original for backup, but USE WITH CAUTION')
args = parser.parse_args()
# console.print(args)
console.print("--- Space Haven Saved Game Inspector ---")
console.print()
filename = os.path.join(DEFAULT_SAVEGAMEPATH, args.filename, "save", "game")
# console.print(filename)
full_text = ""
for line in open(filename):
full_text += line
# console.print(full_text)
soup = BeautifulSoup(full_text, "xml")
Item.load_ids('item_ids.tsv')
console.print("Item code database successfully loaded.")
console.print()
Character.load_ids('char_skills.tsv', 'char_attributes.tsv', 'char_traits.tsv', 'char_conditions.tsv')
console.print("Character code database successfully loaded.")
console.print()
ResearchItem.load_ids('research2.tsv')
console.print("Research name database successfully loaded.")
console.print()
game_data = GameData(soup, Item)
game_data.populate()
edits_made = False
if args.buff_chars:
console.print("Buffing chars...")
game_data.buff_characters()
edits_made = True
print("Buffed chars")
if args.add_item:
console.print("Adding item...")
game_data.add_item(args.add_item[0], args.add_item[1])
edits_made = True
if args.money:
console.print("Adding currency...")
game_data.add_currency(args.money[0])
edits_made = True
if args.list_ships:
console.print("Listing ships...")
game_data.print_summary()
if args.clone_character:
console.print("Char clone requested...")
game_data.clone_character(args.clone_character[0], args.clone_character[1])
edits_made = True
if args.add_item_set:
console.print("Adding item set...")
item_list = parse_item_file(args.add_item_set[0])
# console.print(item_list)
console.print("Items to be added:")
for (item_code, item_quantity) in item_list:
console.print("{} : {}".format(Item.get_name_from_code(item_code), item_quantity))
game_data.add_item(item_code, item_quantity)
edits_made = True
if args.detailed_items:
console.print("Printing detailed item list...")
game_data.print_detailed_item_summary()
if args.detailed_chars:
console.print("Printing detailed character list...")
game_data.print_detailed_character_summary()
if args.redistribute:
console.print("Item redistribution requested...")
game_data.redistribute()
if args.list_research:
console.print("Listing research status...")
game_data.list_research()
if args.consolidate_weapons:
console.print("Consolidating weapons...")
game_data.consolidate_weapons()
game_data.writeback()
if args.test_gamedata:
game_data = GameData(soup)
game_data.populate()
# game_data.add_item(1759, 100)
# game_data.buff_characters()
# game_data.add_currency(10000)
game_data.print_summary()
game_data.print_detailed_item_summary()
# game_data.clone_character("Byron", "Anthony")
# game_data.print_detailed_character_summary()
game_data.writeback()
if edits_made:
if args.replace_original:
console.print('Renaming original file')
datetime_suffix = datetime.datetime.now().strftime('%Y-%m-%d-%H%M_%S_%f')
new_filename = args.filename + "-" + datetime_suffix
# console.print(datetime_suffix)
# os.rename(args.filename, new_filename)
console.print('Now rewriting game file with new information')
text = soup.prettify()
# Delete XML header - game doesn't have it
sansfirstline = '\n'.join(text.split('\n')[1:])
filename = os.path.join(DEFAULT_SAVEGAMEPATH, args.filename, "save", "game")
f = open(filename, 'w')
f.write(sansfirstline)
f.close()
console.print('Complete.')
else:
text = soup.prettify()
# Delete XML header - game doesn't have it
sansfirstline = '\n'.join(text.split('\n')[1:])
f = open('game.xml', 'w')
f.write(sansfirstline)
f.close()
if __name__ == "__main__":
main()