JSON Savefiles | Player Saves use JSON (#70492)

<!-- Write **BELOW** The Headers and **ABOVE** The comments else it may
not be viewable. -->
<!-- You can view Contributing.MD for a detailed description of the pull
request process. -->
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

<!-- Describe The Pull Request. Please be sure every change is
documented or this can delay review and even discourage maintainers from
merging your PR! -->

## Why It's Good For The Game

Permission obtained from MSO and Mothblocks.

<!-- Argue for the merits of your changes and how they benefit the game,
especially if they are controversial and/or far reaching. If you can't
actually explain WHY what you are doing will improve the game, then it
probably isn't good for the game in the first place. -->

## Changelog

<!-- If your PR modifies aspects of the game that can be concretely
observed by players or admins you should add a changelog. If your change
does NOT meet this description, remove this section. Be sure to properly
mark your PRs to prevent unnecessary GBP loss. You can read up on GBP
and it's effects on PRs in the tgstation guides for contributors. Please
note that maintainers freely reserve the right to remove and add tags
should they deem it appropriate. You can attempt to finagle the system
all you want, but it's best to shoot for clear communication right off
the bat. -->
Not player facing, tested locally exhaustively to ensure it doesnt break
shit
🆑
/🆑

<!-- Both 🆑's are required for the changelog to work! You can put
your name to the right of the first 🆑 if you want to overwrite your
GitHub username as author ingame. -->
<!-- You can use multiple of the same prefix (they're only used for the
icon ingame) and delete the unneeded ones. Despite some of the tags,
changelogs should generally represent how a player might be affected by
the changes rather than a summary of the PR's contents. -->

Co-authored-by: Kyle Spier-Swenson <kyleshome@gmail.com>
Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com>
Co-authored-by: san7890 <the@san7890.com>
This commit is contained in:
Zephyr
2022-11-17 21:45:18 -08:00
committed by GitHub
co-authored by Kyle Spier-Swenson Mothblocks san7890
parent 6b6151e16d
commit 47ba13033f
16 changed files with 309 additions and 148 deletions
+19 -16
View File
@@ -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
+15 -24
View File
@@ -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
@@ -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"
@@ -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
+65 -97
View File
@@ -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