From 47ba13033f142ba1c076c36faa8a656ac9fd149a Mon Sep 17 00:00:00 2001 From: Zephyr <12817816+ZephyrTFA@users.noreply.github.com> Date: Fri, 18 Nov 2022 00:45:18 -0500 Subject: [PATCH] JSON Savefiles | Player Saves use JSON (#70492) TODO: - [x] DOCUMENT SHIT - [x] UPDATE DOCUMENTATION ## About The Pull Request Adds a new datum, which is intended to be a replacement for the stock savefile type, json_savefile As you can imagine, this is essentially just a wrapper around a json file for reading/writing/manipulation that is intended to be a dropin replacement for savefiles It also have the ability to import stock savefiles and parse them into a json tree ## Why It's Good For The Game Permission obtained from MSO and Mothblocks. ## Changelog Not player facing, tested locally exhaustively to ensure it doesnt break shit :cl: /:cl: Co-authored-by: Kyle Spier-Swenson Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com> Co-authored-by: san7890 --- code/__DEFINES/client.dm | 2 + code/datums/json_savefile.dm | 76 ++++++++ code/datums/mocking/client.dm | 27 +++ code/modules/client/preferences.dm | 35 ++-- .../modules/client/preferences/_preference.dm | 39 ++--- .../migrations/body_type_migration.dm | 8 +- .../migrations/convert_to_json_savefile.dm | 10 ++ code/modules/client/preferences_savefile.dm | 162 +++++++----------- .../mob/living/carbon/human/human_helpers.dm | 2 +- code/modules/unit_tests/_unit_tests.dm | 1 + code/modules/unit_tests/anonymous_themes.dm | 3 +- .../unit_tests/json_savefile_importing.dm | 83 +++++++++ code/modules/unit_tests/nuke_cinematic.dm | 2 +- code/modules/unit_tests/preferences.dm | 2 +- .../security_officer_distribution.dm | 2 +- tgstation.dme | 3 + 16 files changed, 309 insertions(+), 148 deletions(-) create mode 100644 code/__DEFINES/client.dm create mode 100644 code/datums/json_savefile.dm create mode 100644 code/modules/client/preferences/migrations/convert_to_json_savefile.dm create mode 100644 code/modules/unit_tests/json_savefile_importing.dm diff --git a/code/__DEFINES/client.dm b/code/__DEFINES/client.dm new file mode 100644 index 00000000000..002c2142f15 --- /dev/null +++ b/code/__DEFINES/client.dm @@ -0,0 +1,2 @@ +/// Checks if the given target is either a client or a mock client +#define IS_CLIENT_OR_MOCK(target) (istype(target, /client) || istype(target, /datum/client_interface)) diff --git a/code/datums/json_savefile.dm b/code/datums/json_savefile.dm new file mode 100644 index 00000000000..8884fa60c5d --- /dev/null +++ b/code/datums/json_savefile.dm @@ -0,0 +1,76 @@ +/** + * A savefile implementation that handles all data using json. + * Also saves it using JSON too, fancy. + */ +/datum/json_savefile + var/path = "" + VAR_PRIVATE/list/tree + /// If this is set to true, calling set_entry or remove_entry will automatically call save(), this does not catch modifying a sub-tree, nor do I know how to do that + var/auto_save = FALSE + +GENERAL_PROTECT_DATUM(/datum/json_savefile) + +/datum/json_savefile/New(path) + src.path = path + tree = list() + if(fexists(path)) + load() + +/** + * Gets an entry from the json tree, with an optional default value. + * If no key is specified it throws the entire tree at you instead + */ +/datum/json_savefile/proc/get_entry(key, default_value) + if(!key) + return tree + return (key in tree) ? tree[key] : default_value + +/// Sets an entry in the tree to the given value +/datum/json_savefile/proc/set_entry(key, value) + tree[key] = value + if(auto_save) + save() + +/// Removes the given key from the tree +/datum/json_savefile/proc/remove_entry(key) + if(key) + tree -= key + if(auto_save) + save() + +/// Wipes the entire tree +/datum/json_savefile/proc/wipe() + tree?.Cut() + +/datum/json_savefile/proc/load() + if(!fexists(path)) + return FALSE + try + tree = json_decode(rustg_file_read(path)) + return TRUE + catch(var/exception/err) + stack_trace("failed to load json savefile at '[path]': [err]") + return FALSE + +/datum/json_savefile/proc/save() + rustg_file_write(json_encode(tree), path) + +/// Traverses the entire dir tree of the given savefile and dynamically assembles the tree from it +/datum/json_savefile/proc/import_byond_savefile(savefile/savefile) + tree.Cut() + var/list/dirs_to_go = list("/" = tree) + while(length(dirs_to_go)) + var/dir = dirs_to_go[1] + var/list/region = dirs_to_go[dir] + dirs_to_go.Cut(1, 2) + savefile.cd = dir + for(var/entry in savefile.dir) + var/entry_value + savefile.cd = "[dir]/[entry]" + //eof refers to the path you are cd'ed into, not the savefile as a whole. being false right after cding into an entry means this entry has no buffer, which only happens with nested save file directories + if (savefile.eof) + region[entry] = list() + dirs_to_go["[dir]/[entry]"] = region[entry] + continue + READ_FILE(savefile, entry_value) //we are cd'ed to the entry, so we don't need to specify a path to read from + region[entry] = entry_value diff --git a/code/datums/mocking/client.dm b/code/datums/mocking/client.dm index fd99e34520c..8e09883ae21 100644 --- a/code/datums/mocking/client.dm +++ b/code/datums/mocking/client.dm @@ -6,8 +6,35 @@ /// The view of the client, similar to /client/var/view. var/view = "15x15" + /// View data of the client, similar to /client/var/view_size. + var/datum/view_data/view_size + /// Objects on the screen of the client var/list/screen = list() /// The mob the client controls var/mob/mob + + /// The ckey for this mock interface + var/ckey = "mockclient" + + /// The key for this mock interface + var/key = "mockclient" + + /// client prefs + var/fps + var/hotkeys + var/tgui_say + var/typing_indicators + +/datum/client_interface/proc/IsByondMember() + return FALSE + +/datum/client_interface/New(key) + ..() + if(key) + src.key = key + ckey = ckey(key) + +/datum/client_interface/proc/set_macros() + return diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index 1ea1de11d1a..3391ca7f5fb 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -66,11 +66,11 @@ GLOBAL_LIST_EMPTY(preferences_datums) /// A list of instantiated middleware var/list/datum/preference_middleware/middleware = list() - /// The savefile relating to core preferences, PREFERENCE_PLAYER - var/savefile/game_savefile + /// The json savefile for this datum + var/datum/json_savefile/savefile /// The savefile relating to character preferences, PREFERENCE_CHARACTER - var/savefile/character_savefile + var/list/character_data /// A list of keys that have been updated since the last save. var/list/recently_updated_keys = list() @@ -88,18 +88,23 @@ GLOBAL_LIST_EMPTY(preferences_datums) value_cache = null return ..() -/datum/preferences/New(client/C) - parent = C +/datum/preferences/New(client/parent) + src.parent = parent for (var/middleware_type in subtypesof(/datum/preference_middleware)) middleware += new middleware_type(src) - if(istype(C)) - if(!is_guest_key(C.key)) - load_path(C.ckey) - unlock_content = !!C.IsByondMember() + if(IS_CLIENT_OR_MOCK(parent)) + if(!is_guest_key(parent.key)) + load_path(parent.ckey) + if(!fexists(path)) + try_savefile_type_migration() + unlock_content = !!parent.IsByondMember() if(unlock_content) max_save_slots = 8 + else + CRASH("attempted to create a preferences datum without a client or mock!") + load_savefile() // give them default keybinds and update their movement keys key_bindings = deep_copy_list(GLOB.default_hotkeys) @@ -112,9 +117,9 @@ GLOBAL_LIST_EMPTY(preferences_datums) return //we couldn't load character data so just randomize the character appearance + name randomise_appearance_prefs() //let's create a random character then - rather than a fat, bald and naked man. - if(C) + if(parent) apply_all_client_preferences() - C.set_macros() + parent.set_macros() if(!loaded_preferences_successfully) save_preferences() @@ -370,17 +375,15 @@ GLOBAL_LIST_EMPTY(preferences_datums) /datum/preferences/proc/create_character_profiles() var/list/profiles = list() - var/savefile/savefile = new(path) for (var/index in 1 to max_save_slots) // It won't be updated in the savefile yet, so just read the name directly if (index == default_slot) profiles += read_preference(/datum/preference/name/real_name) continue - savefile.cd = "/character[index]" - - var/name - READ_FILE(savefile["real_name"], name) + var/tree_key = "character[index]" + var/save_data = savefile.get_entry(tree_key) + var/name = save_data?["real_name"] if (isnull(name)) profiles += null diff --git a/code/modules/client/preferences/_preference.dm b/code/modules/client/preferences/_preference.dm index 6d77a5f8451..db56746dbc9 100644 --- a/code/modules/client/preferences/_preference.dm +++ b/code/modules/client/preferences/_preference.dm @@ -156,13 +156,13 @@ GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key()) /// Given a savefile, return either the saved data or an acceptable default. /// This will write to the savefile if a value was not found with the new value. -/datum/preference/proc/read(savefile/savefile, datum/preferences/preferences) +/datum/preference/proc/read(list/save_data, datum/preferences/preferences) SHOULD_NOT_OVERRIDE(TRUE) var/value - if (!isnull(savefile)) - READ_FILE(savefile[savefile_key], value) + if (!isnull(save_data)) + value = save_data[savefile_key] if (isnull(value)) return null @@ -172,14 +172,14 @@ GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key()) /// Given a savefile, writes the inputted value. /// Returns TRUE for a successful application. /// Return FALSE if it is invalid. -/datum/preference/proc/write(savefile/savefile, value) +/datum/preference/proc/write(list/save_data, value) SHOULD_NOT_OVERRIDE(TRUE) if (!is_valid(value)) return FALSE - if (!isnull(savefile)) - WRITE_FILE(savefile[savefile_key], serialize(value)) + if (!isnull(save_data)) + save_data[savefile_key] = serialize(value) return TRUE @@ -205,31 +205,22 @@ GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key()) CRASH("`apply_to_human()` was not implemented for [type]!") /// Returns which savefile to use for a given savefile identifier -/datum/preferences/proc/get_savefile_for_savefile_identifier(savefile_identifier) - RETURN_TYPE(/savefile) +/datum/preferences/proc/get_save_data_for_savefile_identifier(savefile_identifier) + RETURN_TYPE(/list) if (!parent) return null + if(!savefile) + CRASH("Attempted to get the savedata for [savefile_identifier] of [parent] without a savefile. This should have been handled by load_preferences()") // Both of these will cache savefiles, but only for a tick. // This is because storing a savefile will lock it, causing later issues down the line. // Do not change them to addtimer, since the timer SS might not be running at this time. - switch (savefile_identifier) if (PREFERENCE_CHARACTER) - if (!character_savefile) - character_savefile = new /savefile(path) - character_savefile.cd = "/character[default_slot]" - spawn (1) - character_savefile = null - return character_savefile + return savefile.get_entry("character[default_slot]") if (PREFERENCE_PLAYER) - if (!game_savefile) - game_savefile = new /savefile(path) - game_savefile.cd = "/" - spawn (1) - game_savefile = null - return game_savefile + return savefile.get_entry() else CRASH("Unknown savefile identifier [savefile_identifier]") @@ -252,7 +243,7 @@ GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key()) if (preference_type in value_cache) return value_cache[preference_type] - var/value = preference_entry.read(get_savefile_for_savefile_identifier(preference_entry.savefile_identifier), src) + var/value = preference_entry.read(get_save_data_for_savefile_identifier(preference_entry.savefile_identifier), src) if (isnull(value)) value = preference_entry.create_informed_default_value(src) if (write_preference(preference_entry, value)) @@ -266,9 +257,9 @@ GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key()) /// Returns TRUE for a successful preference application. /// Returns FALSE if it is invalid. /datum/preferences/proc/write_preference(datum/preference/preference, preference_value) - var/savefile = get_savefile_for_savefile_identifier(preference.savefile_identifier) + var/save_data = get_save_data_for_savefile_identifier(preference.savefile_identifier) var/new_value = preference.deserialize(preference_value, src) - var/success = preference.write(savefile, new_value) + var/success = preference.write(save_data, new_value) if (success) value_cache[preference.type] = new_value return success diff --git a/code/modules/client/preferences/migrations/body_type_migration.dm b/code/modules/client/preferences/migrations/body_type_migration.dm index df599fd9aea..2254de7f297 100644 --- a/code/modules/client/preferences/migrations/body_type_migration.dm +++ b/code/modules/client/preferences/migrations/body_type_migration.dm @@ -2,9 +2,7 @@ /// PR #62733 changed this to allow all characters to use body type. /// This migration moves binary-gendered characters over to the "use gender" body type /// so that old characters are preserved. -/datum/preferences/proc/migrate_body_types(savefile/savefile) - var/current_gender - - READ_FILE(savefile["gender"], current_gender) +/datum/preferences/proc/migrate_body_types(list/save_data) + var/current_gender = save_data["gender"] if (current_gender == MALE || current_gender == FEMALE) - WRITE_FILE(savefile["body_type"], "Use gender") + save_data["body_type"] = "Use gender" diff --git a/code/modules/client/preferences/migrations/convert_to_json_savefile.dm b/code/modules/client/preferences/migrations/convert_to_json_savefile.dm new file mode 100644 index 00000000000..29d4600816f --- /dev/null +++ b/code/modules/client/preferences/migrations/convert_to_json_savefile.dm @@ -0,0 +1,10 @@ +/datum/preferences/proc/try_savefile_type_migration() + load_path(parent.ckey, "preferences.sav") // old save file + var/old_path = path + load_path(parent.ckey) + if(!fexists(old_path)) + return + var/datum/json_savefile/json_savefile = new(path) + json_savefile.import_byond_savefile(new /savefile(old_path)) + json_savefile.save() + return TRUE diff --git a/code/modules/client/preferences_savefile.dm b/code/modules/client/preferences_savefile.dm index 8ef913d4f2d..b6c69cc2f03 100644 --- a/code/modules/client/preferences_savefile.dm +++ b/code/modules/client/preferences_savefile.dm @@ -23,15 +23,13 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car Failing all that, the standard sanity checks are performed. They simply check the data is suitable, reverting to initial() values if necessary. */ -/datum/preferences/proc/savefile_needs_update(savefile/S) - var/savefile_version - READ_FILE(S["version"], savefile_version) - - if(savefile_version < SAVEFILE_VERSION_MIN) - S.dir.Cut() +/datum/preferences/proc/save_data_needs_update(list/save_data) + if(!save_data) // empty list, either savefile isnt loaded or its a new char + return -1 + if(save_data["version"] < SAVEFILE_VERSION_MIN) return -2 - if(savefile_version < SAVEFILE_VERSION_MAX) - return savefile_version + if(save_data["version"] < SAVEFILE_VERSION_MAX) + return save_data["version"] return -1 //should these procs get fairly long @@ -41,7 +39,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car //This only really meant to avoid annoying frequent players //if your savefile is 3 months out of date, then 'tough shit'. -/datum/preferences/proc/update_preferences(current_version, savefile/S) +/datum/preferences/proc/update_preferences(current_version, datum/json_savefile/S) if(current_version < 34) write_preference(/datum/preference/toggle/auto_fit_viewport, TRUE) @@ -93,12 +91,12 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car if (current_version < 41) migrate_preferences_to_tgui_prefs_menu() -/datum/preferences/proc/update_character(current_version, savefile/savefile) +/datum/preferences/proc/update_character(current_version, list/save_data) if (current_version < 41) migrate_character_to_tgui_prefs_menu() if (current_version < 42) - migrate_body_types(savefile) + migrate_body_types(save_data) if (current_version < 43) migrate_legacy_sound_toggles(savefile) @@ -141,50 +139,48 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car var/datum/keybinding/conflicted = item to_chat(parent, span_danger("[conflicted.category]: [conflicted.full_name] needs updating")) - -/datum/preferences/proc/load_path(ckey,filename="preferences.sav") +/datum/preferences/proc/load_path(ckey, filename="preferences.json") if(!ckey) return path = "data/player_saves/[ckey[1]]/[ckey]/[filename]" -/datum/preferences/proc/load_preferences() +/datum/preferences/proc/load_savefile() if(!path) - return FALSE - if(!fexists(path)) - return FALSE + CRASH("Attempted to load savefile without first loading a path!") + savefile = new /datum/json_savefile(path) - var/savefile/S = new /savefile(path) - if(!S) - return FALSE - S.cd = "/" +/datum/preferences/proc/load_preferences() + if(!savefile) + stack_trace("Attempted to load the preferences of [parent] without a savefile; did you forget to call load_savefile?") + load_savefile() + if(!savefile) + stack_trace("Failed to load the savefile for [parent] after manually calling load_savefile; something is very wrong.") + return FALSE - var/needs_update = savefile_needs_update(S) + var/needs_update = save_data_needs_update(savefile.get_entry()) if(needs_update == -2) //fatal, can't load any data var/bacpath = "[path].updatebac" //todo: if the savefile version is higher then the server, check the backup, and give the player a prompt to load the backup if (fexists(bacpath)) fdel(bacpath) //only keep 1 version of backup - fcopy(S, bacpath) //byond helpfully lets you use a savefile for the first arg. + fcopy(savefile.path, bacpath) //byond helpfully lets you use a savefile for the first arg. return FALSE apply_all_client_preferences() //general preferences - READ_FILE(S["lastchangelog"], lastchangelog) - - READ_FILE(S["be_special"] , be_special) - - - READ_FILE(S["default_slot"], default_slot) - READ_FILE(S["chat_toggles"], chat_toggles) - READ_FILE(S["toggles"], toggles) - READ_FILE(S["ignoring"], ignoring) + lastchangelog = savefile.get_entry("lastchangelog") + be_special = savefile.get_entry("be_special") + default_slot = savefile.get_entry("default_slot") + chat_toggles = savefile.get_entry("chat_toggles") + toggles = savefile.get_entry("toggles") + ignoring = savefile.get_entry("ignoring") // OOC commendations - READ_FILE(S["hearted_until"], hearted_until) + hearted_until = savefile.get_entry("hearted_until") if(hearted_until > world.realtime) hearted = TRUE //favorite outfits - READ_FILE(S["favorite_outfits"], favorite_outfits) + favorite_outfits = savefile.get_entry("favorite_outfits") var/list/parsed_favs = list() for(var/typetext in favorite_outfits) @@ -194,15 +190,15 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car favorite_outfits = unique_list(parsed_favs) // Custom hotkeys - READ_FILE(S["key_bindings"], key_bindings) + key_bindings = savefile.get_entry("key_bindings") //try to fix any outdated data if necessary if(needs_update >= 0) var/bacpath = "[path].updatebac" //todo: if the savefile version is higher then the server, check the backup, and give the player a prompt to load the backup if (fexists(bacpath)) fdel(bacpath) //only keep 1 version of backup - fcopy(S, bacpath) //byond helpfully lets you use a savefile for the first arg. - update_preferences(needs_update, S) //needs_update = savefile_version if we need an update (positive integer) + fcopy(savefile.path, bacpath) //byond helpfully lets you use a savefile for the first arg. + update_preferences(needs_update, savefile) //needs_update = savefile_version if we need an update (positive integer) check_keybindings() // this apparently fails every time and overwrites any unloaded prefs with the default values, so don't load anything after this line or it won't actually save key_bindings_by_key = get_key_bindings_by_key(key_bindings) @@ -219,7 +215,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car var/old_default_slot = default_slot var/old_max_save_slots = max_save_slots - for (var/slot in S.dir) //but first, update all current character slots. + for (var/slot in savefile.get_entry()) //but first, update all current character slots. if (copytext(slot, 1, 10) != "character") continue var/slotnum = text2num(copytext(slot, 10)) @@ -236,14 +232,9 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car return TRUE /datum/preferences/proc/save_preferences() - if(!path) - return FALSE - var/savefile/S = new /savefile(path) - if(!S) - return FALSE - S.cd = "/" - - WRITE_FILE(S["version"] , SAVEFILE_VERSION_MAX) //updates (or failing that the sanity checks) will ensure data is not invalid at load. Assume up-to-date + if(!savefile) + CRASH("Attempted to save the preferences of [parent] without a savefile. This should have been handled by load_preferences()") + savefile.set_entry("version", SAVEFILE_VERSION_MAX) //updates (or failing that the sanity checks) will ensure data is not invalid at load. Assume up-to-date for (var/preference_type in GLOB.preference_entries) var/datum/preference/preference = GLOB.preference_entries[preference_type] @@ -258,41 +249,31 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car if (preference_type in value_cache) write_preference(preference, preference.serialize(value_cache[preference_type])) - //general preferences - WRITE_FILE(S["lastchangelog"], lastchangelog) - WRITE_FILE(S["be_special"], be_special) - WRITE_FILE(S["default_slot"], default_slot) - WRITE_FILE(S["toggles"], toggles) - WRITE_FILE(S["chat_toggles"], chat_toggles) - WRITE_FILE(S["ignoring"], ignoring) - WRITE_FILE(S["key_bindings"], key_bindings) - WRITE_FILE(S["hearted_until"], (hearted_until > world.realtime ? hearted_until : null)) - WRITE_FILE(S["favorite_outfits"], favorite_outfits) + savefile.set_entry("lastchangelog", lastchangelog) + savefile.set_entry("be_special", be_special) + savefile.set_entry("default_slot", default_slot) + savefile.set_entry("toggles", toggles) + savefile.set_entry("chat_toggles", chat_toggles) + savefile.set_entry("ignoring", ignoring) + savefile.set_entry("key_bindings", key_bindings) + savefile.set_entry("hearted_until", (hearted_until > world.realtime ? hearted_until : null)) + savefile.set_entry("favorite_outfits", favorite_outfits) + savefile.save() return TRUE /datum/preferences/proc/load_character(slot) SHOULD_NOT_SLEEP(TRUE) - if(!path) - return FALSE - if(!fexists(path)) - return FALSE - - character_savefile = null - - var/savefile/S = new /savefile(path) - if(!S) - return FALSE - S.cd = "/" if(!slot) slot = default_slot slot = sanitize_integer(slot, 1, max_save_slots, initial(default_slot)) if(slot != default_slot) default_slot = slot - WRITE_FILE(S["default_slot"] , slot) + savefile.set_entry("default_slot", slot) - S.cd = "/character[slot]" - var/needs_update = savefile_needs_update(S) + var/tree_key = "character[slot]" + var/list/save_data = savefile.get_entry(tree_key) + var/needs_update = save_data_needs_update(save_data) if(needs_update == -2) //fatal, can't load any data return FALSE @@ -306,22 +287,23 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car read_preference(preference_type) //Character - READ_FILE(S["randomise"], randomise) + randomise = save_data?["randomise"] //Load prefs - READ_FILE(S["job_preferences"], job_preferences) + job_preferences = save_data?["job_preferences"] //Quirks - READ_FILE(S["all_quirks"], all_quirks) + all_quirks = save_data?["all_quirks"] //try to fix any outdated data if necessary //preference updating will handle saving the updated data for us. if(needs_update >= 0) - update_character(needs_update, S) //needs_update == savefile_version if we need an update (positive integer) + update_character(needs_update, save_data) //needs_update == savefile_version if we need an update (positive integer) //Sanitize randomise = SANITIZE_LIST(randomise) - + job_preferences = SANITIZE_LIST(job_preferences) + all_quirks = SANITIZE_LIST(all_quirks) //Validate job prefs for(var/j in job_preferences) @@ -338,10 +320,10 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car if(!path) return FALSE - var/savefile/S = new /savefile(path) - if(!S) - return FALSE - S.cd = "/character[default_slot]" + var/tree_key = "character[default_slot]" + if(!(tree_key in savefile.get_entry())) + savefile.set_entry(tree_key, list()) + var/save_data = savefile.get_entry(tree_key) for (var/datum/preference/preference as anything in get_preferences_in_priority_order()) if (preference.savefile_identifier != PREFERENCE_CHARACTER) @@ -355,7 +337,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car if (preference.type in value_cache) write_preference(preference, preference.serialize(value_cache[preference.type])) - WRITE_FILE(S["version"] , SAVEFILE_VERSION_MAX) //load_character will sanitize any bad data, so assume up-to-date.) + save_data["version"] = SAVEFILE_VERSION_MAX //load_character will sanitize any bad data, so assume up-to-date. // This is the version when the random security department was removed. // When the minimum is higher than that version, it's impossible for someone to have the "Random" department. @@ -364,13 +346,13 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car #endif //Character - WRITE_FILE(S["randomise"] , randomise) + save_data["randomise"] = randomise //Write prefs - WRITE_FILE(S["job_preferences"] , job_preferences) + save_data["job_preferences"] = job_preferences //Quirks - WRITE_FILE(S["all_quirks"] , all_quirks) + save_data["all_quirks"] = all_quirks return TRUE @@ -392,17 +374,3 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car #undef SAVEFILE_VERSION_MAX #undef SAVEFILE_VERSION_MIN - -#ifdef TESTING -//DEBUG -//Some crude tools for testing savefiles -//path is the savefile path -/client/verb/savefile_export(path as text) - var/savefile/S = new /savefile(path) - S.ExportText("/",file("[path].txt")) -//path is the savefile path -/client/verb/savefile_import(path as text) - var/savefile/S = new /savefile(path) - S.ImportText("/",file("[path].txt")) - -#endif diff --git a/code/modules/mob/living/carbon/human/human_helpers.dm b/code/modules/mob/living/carbon/human/human_helpers.dm index 364ef57942a..701182c4d51 100644 --- a/code/modules/mob/living/carbon/human/human_helpers.dm +++ b/code/modules/mob/living/carbon/human/human_helpers.dm @@ -233,7 +233,7 @@ /// Fully randomizes everything according to the given flags. /mob/living/carbon/human/proc/randomize_human_appearance(randomize_flags = ALL) - var/datum/preferences/preferences = new + var/datum/preferences/preferences = new(new /datum/client_interface) for (var/datum/preference/preference as anything in get_preferences_in_priority_order()) if (!preference.included_in_randomization_flags(randomize_flags)) diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index 6a2d13ffe55..eac28e1873a 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -117,6 +117,7 @@ #include "hydroponics_self_mutations.dm" #include "hydroponics_validate_genes.dm" #include "inhands.dm" +#include "json_savefile_importing.dm" #include "keybinding_init.dm" #include "knockoff_component.dm" #include "limbsanity.dm" diff --git a/code/modules/unit_tests/anonymous_themes.dm b/code/modules/unit_tests/anonymous_themes.dm index 910317c36aa..5eb2a1f48f3 100644 --- a/code/modules/unit_tests/anonymous_themes.dm +++ b/code/modules/unit_tests/anonymous_themes.dm @@ -8,8 +8,7 @@ var/datum/client_interface/client = new human.mock_client = client - - client.prefs = new + client.prefs = new(client) client.prefs.write_preference(GLOB.preference_entries[/datum/preference/name/real_name], "Prefs Biddle") diff --git a/code/modules/unit_tests/json_savefile_importing.dm b/code/modules/unit_tests/json_savefile_importing.dm new file mode 100644 index 00000000000..c1933000ddc --- /dev/null +++ b/code/modules/unit_tests/json_savefile_importing.dm @@ -0,0 +1,83 @@ +/** + * The job of this unit test is to ensure that save files are correctly imported from BYOND to JSON. + * It's a rather convoluted process and so this test ensures that something didn't fuck up somewhere. + */ +/datum/unit_test/json_savefiles + var/savefile/test_savefile + var/datum/json_savefile/json_savefile + + var/list/basic_list + var/list/assoc_list + var/var_string + +/datum/unit_test/json_savefiles/proc/setup() + var/path_byond_file = "data/json_savefile_test.sav" + var/path_json_file = "data/json_savefile_test.json" + if(fexists(path_byond_file)) + fdel(path_byond_file) + if(fexists(path_json_file)) + fdel(path_json_file) + test_savefile = new /savefile(path_byond_file) + json_savefile = new /datum/json_savefile(path_json_file) + + var_string = random_nukecode() + basic_list = list(rand(), rand(), rand(), "3", "6", "null", "\proper house") + assoc_list = list("2" = rand(), "4" = "3", "341" = "15134123", "\[22\]\[\]\[\[") + + test_savefile["basic_list"] << basic_list + test_savefile["assoc_list"] << assoc_list + + test_savefile["null_value"] << null + test_savefile["empty_list"] << list() + + test_savefile.cd = "/v1/v2" + test_savefile["var_string"] << var_string + +/datum/unit_test/json_savefiles/Run() + setup() + + // first, we import the file to json + json_savefile.import_byond_savefile(test_savefile) + + // now we seperate out the different values + var/byond_basic_list = json_encode(basic_list) + var/json_basic_list = json_encode(json_savefile.get_entry("basic_list")) + TEST_ASSERT_EQUAL(byond_basic_list, json_basic_list, "didn't convert basic list correctly") + + var/byond_assoc_list = json_encode(assoc_list) + var/json_assoc_list = json_encode(json_savefile.get_entry("assoc_list")) + TEST_ASSERT_EQUAL(byond_assoc_list, json_assoc_list, "didn't convert associative list correctly") + + var/null_value = json_savefile.get_entry("null_value") + var/default_value = json_savefile.get_entry("this_key_doesnt_exist", "defval") + TEST_ASSERT_NULL(null_value, "read an invalid value for what should be null") + TEST_ASSERT_EQUAL(default_value, "defval", "didn't grab the default value for a non existant key") + + var/empty_list = json_savefile.get_entry("empty_list") + if(!istype(empty_list, /list)) + TEST_FAIL("empty_list was not a list") + else + if(length(empty_list)) + TEST_FAIL("empty_list was not empty") + + var/empty_list_check_default = json_savefile.get_entry("empty_list", "123") + TEST_ASSERT_NOTEQUAL(empty_list_check_default, "123", "grabbed the default value for a key when key exists in tree") + + // Now we check to ensure dir traversal is working as intended + // we are expecting v1 -> v2 -> var_string + var/dir_v1 = json_savefile.get_entry("v1") + var/dir_v2 = dir_v1?["v2"] + var/dir_string = dir_v2?["var_string"] + TEST_ASSERT_EQUAL(dir_string, var_string, "didn't traverse dirs correctly") + + var/runtime_check_string = random_nukecode() + json_savefile.auto_save = TRUE + json_savefile.set_entry("runtime_saving", runtime_check_string) + var/runtime_read = json_savefile.get_entry("runtime_saving") + TEST_ASSERT_EQUAL(runtime_check_string, runtime_read, "wrote and read the same key but got different values") + json_savefile.wipe() + runtime_read = json_savefile.get_entry("runtime_saving") + TEST_ASSERT_NULL(runtime_read, "wiped the tree but data remained") + json_savefile.load() + runtime_read = json_savefile.get_entry("runtime_saving") + TEST_ASSERT_EQUAL(runtime_check_string, runtime_read, "saved and read the same key but got different values, auto save didn't work as expected") diff --git a/code/modules/unit_tests/nuke_cinematic.dm b/code/modules/unit_tests/nuke_cinematic.dm index fbbfeabbd10..41430466d35 100644 --- a/code/modules/unit_tests/nuke_cinematic.dm +++ b/code/modules/unit_tests/nuke_cinematic.dm @@ -16,7 +16,7 @@ /datum/unit_test/nuke_cinematic/Run() var/obj/machinery/nuclearbomb/syndicate/nuke = allocate(/obj/machinery/nuclearbomb/syndicate) var/mob/living/carbon/human/nuked = allocate(/mob/living/carbon/human) - var/datum/client_interface/mock_client = new() + var/datum/client_interface/mock_client = new nuked.mock_client = mock_client mock_client.mob = nuked diff --git a/code/modules/unit_tests/preferences.dm b/code/modules/unit_tests/preferences.dm index 346188447fe..27389dca824 100644 --- a/code/modules/unit_tests/preferences.dm +++ b/code/modules/unit_tests/preferences.dm @@ -2,7 +2,7 @@ /datum/unit_test/preferences_implement_everything /datum/unit_test/preferences_implement_everything/Run() - var/datum/preferences/preferences = new + var/datum/preferences/preferences = new(new /datum/client_interface) var/mob/living/carbon/human/human = allocate(/mob/living/carbon/human) for (var/preference_type in GLOB.preference_entries) diff --git a/code/modules/unit_tests/security_officer_distribution.dm b/code/modules/unit_tests/security_officer_distribution.dm index 9952c02a8a5..643cf95e4eb 100644 --- a/code/modules/unit_tests/security_officer_distribution.dm +++ b/code/modules/unit_tests/security_officer_distribution.dm @@ -58,7 +58,7 @@ var/mob/dead/new_player/new_player = allocate(/mob/dead/new_player) var/datum/client_interface/mock_client = new - mock_client.prefs = new + mock_client.prefs = new(mock_client) var/write_success = mock_client.prefs.write_preference( GLOB.preference_entries[/datum/preference/choiced/security_department], SECURITY_OFFICER_DEPARTMENTS_TO_NAMES[preference], diff --git a/tgstation.dme b/tgstation.dme index 2a7e1dc192e..7c285058228 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -52,6 +52,7 @@ #include "code\__DEFINES\chat.dm" #include "code\__DEFINES\chat_filter.dm" #include "code\__DEFINES\cleaning.dm" +#include "code\__DEFINES\client.dm" #include "code\__DEFINES\clothing.dm" #include "code\__DEFINES\colors.dm" #include "code\__DEFINES\combat.dm" @@ -622,6 +623,7 @@ #include "code\datums\hotkeys_help.dm" #include "code\datums\http.dm" #include "code\datums\hud.dm" +#include "code\datums\json_savefile.dm" #include "code\datums\map_config.dm" #include "code\datums\minigames_menu.dm" #include "code\datums\mood.dm" @@ -2773,6 +2775,7 @@ #include "code\modules\client\preferences\middleware\random.dm" #include "code\modules\client\preferences\middleware\species.dm" #include "code\modules\client\preferences\migrations\body_type_migration.dm" +#include "code\modules\client\preferences\migrations\convert_to_json_savefile.dm" #include "code\modules\client\preferences\migrations\legacy_sound_toggles_migration.dm" #include "code\modules\client\preferences\migrations\tgui_prefs_migration.dm" #include "code\modules\client\preferences\species_features\basic.dm"