From 36d267d83eee2d2a4004eadeba0d787154c12bc5 Mon Sep 17 00:00:00 2001 From: FabianK3 <21039694+FabianK3@users.noreply.github.com> Date: Sun, 17 Aug 2025 00:00:12 +0200 Subject: [PATCH] Persistent data framework (#21055) # Summary This PR has the goal of adding a base framework for tracking, saving and otherwise managing persistent data between rounds, written from scratch. Persistent data can be anything - Most expectedly noticeboards, dirt, papers, etc. _Technical details about the PR below are subject to change._ ## Estimated scope The first iteration of the persistence framework should include the functioning subsystem and the first implementation of a persistent type using said subsystem. - [x] Prepare database for persistent data - [x] Add new features to `/obj/` - [x] Logic to get and load persistent data into objects for the subsystem - [x] Internal logic for tracking and other - [x] Implement subsystem - [x] Basics, files, integration of subsystem - [x] Database queries for different operations - [x] Methods for registering and de-registering object tracking for `/obj` - [x] Round start logic - [x] Round end logic - [x] Implement first persistent type: Papers on notice boards - [ ] Code review - [x] Scope validation - [x] Testing - [x] Full changelog - Merge - [ ] Setup logging config for new subsystem - [x] Documentation for developers ([How to add new persistent types](https://github.com/Aurorastation/Aurora.3/wiki/Persistence)) ## Inner workings The new persistence subsystem has the following concept: At round start the subsystem reads existing data from the database and creates objects for them. During the round objects can be added to the tracking using a register function (or removed by a de-register function). At round end the subsystem goes over all it's actively tracked objects: Create new objects (that don't have a persistent ID from the database), update objects that changed during the round and expire objects that are no longer tracked. The objects in questions can register themselves, de-register themselves and decide themselves what content should be stored and handle how to set themselves up during the subsystems init phase. ## Information on the new table `id` - database generated ID for tracking persistent objects over multiple rounds, objects without an ID are considered new and created during the round. `author_ckey` - With the first implementation unused, but expected to be required in the future to allow any staff/moderation on persistent data. Nullable as persistent data also can be things like decals without an actual owner. `type` - Type of persistent data used for creating objects at round start. `created_at` - Statistical values. `expire_at` - Used for cleaning the database and setting limits for persistent data types. It's optional and allows permanent persistent data (command notice boards?). `content` - JSON formatted data containing the actual properties of an individual tracked object (e.g. what's written on a paper, what's its title). `x`, `y`, `z` - Coordinates taken and given from/to the object during save/load. Those can be null for future purposes. ## The goal on how to add new persistent data types `/obj` received two new methods which need to be overriden in the to-be added new persistent type: `persistence_get_content()` and `persistence_apply_content(content, x, y, z)`. The first method has the responsibility to provide all custom content of a new type to the subsystem in the form of an associated list (`["title" = "Hello, World!", "value" = 1337]. What does that mean? In the example of a piece of paper, the method needs to put the papers title and text content into a an associated list, that list will be saved by the persistence subsystem. The second method has the responsibility to apply the previously saved content when persistent data is initialized at the start of the round: The associated list needs to be read back and applied to the paper, additionally coordinates are provided for the object that have been taken during saving. Note that the coordinates might be null and could be ignored during setup. During the round, when a new object is created, like a paper, you need to call the subsystem and register a track: `SSpersistence.register_track(your_object_to_track, ckey (optional)`. The subsystem will be using the methods above when the round ends and saves the object - and loads it at the start of the round using the second method. The ckey needs to be given for all persistent that contains user generated content, e.g. papers. In case a persistent object gets removed, you need to call `SSpersistence.deregister_track(your_tracked_object)`. At the end of the round the object will be removed from the storage. `/obj` contains the variable (along some other technical vars) `persistance_initial_expiration_time_days`, which has a default value of 30 days, but can be safely overriden on a per-type basis. This value (in days) will be used for the persistent data entry expiration date when a new object of said type is stored in the database. ## PR description changelog - Added *The goal on how to add new persistent data types*. - Updates *Estimated scope* format. - Updates *Inner workings*. - Final iteration. *(Grammer and formatting not tracked manually)* --- SQL/migrate-2023/V017__persistence.sql | 13 + aurorastation.dme | 3 + code/__DEFINES/global.dm | 4 + code/__DEFINES/persistence.dm | 6 + code/__DEFINES/subsystems.dm | 3 +- .../logging/subsystems/persistence.dm | 7 + code/controllers/configuration.dm | 2 + code/controllers/subsystems/persistence.dm | 310 ++++++++++++++++++ code/game/objects/objs.dm | 38 +++ code/game/objects/structures/noticeboard.dm | 8 +- code/modules/paperwork/paper.dm | 19 ++ config/example/logging.json | 1 + config/example/logging_files.json | 1 + .../fabiank3-persistent-data-framework.yml | 8 + 14 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 SQL/migrate-2023/V017__persistence.sql create mode 100644 code/__DEFINES/persistence.dm create mode 100644 code/__HELPERS/logging/subsystems/persistence.dm create mode 100644 code/controllers/subsystems/persistence.dm create mode 100644 html/changelogs/fabiank3-persistent-data-framework.yml diff --git a/SQL/migrate-2023/V017__persistence.sql b/SQL/migrate-2023/V017__persistence.sql new file mode 100644 index 00000000000..07ed8e1b88e --- /dev/null +++ b/SQL/migrate-2023/V017__persistence.sql @@ -0,0 +1,13 @@ +CREATE TABLE ss13_persistent_data ( + id INT NOT NULL AUTO_INCREMENT, + author_ckey VARCHAR(32) NULL, + type VARCHAR(128) NOT NULL, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + content MEDIUMTEXT NOT NULL, + x INT NULL, + y INT NULL, + z INT NULL, + PRIMARY KEY (id), + INDEX idx_expireDate (expires_at) +); diff --git a/aurorastation.dme b/aurorastation.dme index d25f1ae67ec..14c60c5a817 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -112,6 +112,7 @@ #include "code\__DEFINES\outfit.dm" #include "code\__DEFINES\overmap.dm" #include "code\__DEFINES\path.dm" +#include "code\__DEFINES\persistence.dm" #include "code\__DEFINES\pipes.dm" #include "code\__DEFINES\prefs.dm" #include "code\__DEFINES\procpath.dm" @@ -267,6 +268,7 @@ #include "code\__HELPERS\logging\subsystems\law.dm" #include "code\__HELPERS\logging\subsystems\mapfinalization.dm" #include "code\__HELPERS\logging\subsystems\odyssey.dm" +#include "code\__HELPERS\logging\subsystems\persistence.dm" #include "code\__HELPERS\logging\subsystems\ZAS.dm" #include "code\__HELPERS\paths\jps.dm" #include "code\__HELPERS\paths\path.dm" @@ -372,6 +374,7 @@ #include "code\controllers\subsystems\overlays.dm" #include "code\controllers\subsystems\pai.dm" #include "code\controllers\subsystems\pathfinder.dm" +#include "code\controllers\subsystems\persistence.dm" #include "code\controllers\subsystems\ping.dm" #include "code\controllers\subsystems\plants.dm" #include "code\controllers\subsystems\profiler.dm" diff --git a/code/__DEFINES/global.dm b/code/__DEFINES/global.dm index 5b97201e264..157df823171 100644 --- a/code/__DEFINES/global.dm +++ b/code/__DEFINES/global.dm @@ -111,6 +111,10 @@ GLOBAL_VAR(custom_event_msg) GLOBAL_DATUM(dbcon, /DBConnection) GLOBAL_PROTECT(dbcon) +// Persistence subsystem track register - List of all persistent data tracks managed by the subsystem. +GLOBAL_LIST_EMPTY(persistence_register) +GLOBAL_PROTECT(persistence_register) + // Added for Xenoarchaeology, might be useful for other stuff. GLOBAL_LIST_INIT(alphabet_uppercase, list("A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z")) diff --git a/code/__DEFINES/persistence.dm b/code/__DEFINES/persistence.dm new file mode 100644 index 00000000000..90d6ea9eb3f --- /dev/null +++ b/code/__DEFINES/persistence.dm @@ -0,0 +1,6 @@ +/*############################################# + Constants for the persistence subsystem +#############################################*/ + +#define PERSISTENT_DEFAULT_EXPIRATION_DAYS 30 // Default expire timespan for newly created persistent objects +#define PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS 30 // Grace period for expired database entries before they get cleaned up. diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 765fed916ff..ed6bde979f2 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -227,7 +227,8 @@ #define INIT_ORDER_EARLY_ASSETS 48 #define INIT_ORDER_SPATIAL_GRID 43 #define INIT_ORDER_ECONOMY 40 -#define INIT_ORDER_MAPFINALIZE 31 //Asteroid generation, another aurora snowflake, must run before the atoms init +#define INIT_ORDER_MAPFINALIZE 32 // Asteroid generation, another aurora snowflake, must run before the atoms init +#define INIT_ORDER_PERSISTENCE 31 // Persistence subsystem, requires map load #define INIT_ORDER_ATOMS 30 #define INIT_ORDER_MACHINES 20 #define INIT_ORDER_DEFAULT 0 diff --git a/code/__HELPERS/logging/subsystems/persistence.dm b/code/__HELPERS/logging/subsystems/persistence.dm new file mode 100644 index 00000000000..76575df3200 --- /dev/null +++ b/code/__HELPERS/logging/subsystems/persistence.dm @@ -0,0 +1,7 @@ +/proc/log_subsystem_persistence(text) +#if defined(UNIT_TEST) + LOG_GITHUB_DEBUG("SSPersistence: [text]") +#else + if (GLOB.config?.logsettings["log_subsystems_persistence"]) + WRITE_LOG(GLOB.config.logfiles["world_subsystems_persistence_log"], "SSPersistence: [text]") +#endif diff --git a/code/controllers/configuration.dm b/code/controllers/configuration.dm index 01d74f89312..9decda48d81 100644 --- a/code/controllers/configuration.dm +++ b/code/controllers/configuration.dm @@ -62,6 +62,7 @@ GLOBAL_LIST_EMPTY(gamemode_cache) "log_subsystems_ghostroles" = TRUE, // Ghost Roles "log_subsystems_law" = TRUE, // Law "log_subsystems_cargo" = TRUE, // Cargo + "log_subsystems_persistence" = TRUE, // Persistence "log_subsystems_documents" = TRUE, // Documents "log_subsystems_fail2topic" = TRUE, // Fail2Topic "log_subsystems_mapfinalization" = TRUE, // Map Finalization @@ -120,6 +121,7 @@ GLOBAL_LIST_EMPTY(gamemode_cache) "world_subsystems_ghostroles_log" = "subsystems/ghostroles.log", "world_subsystems_law_log" = "subsystems/law.log", "world_subsystems_cargo_log" = "subsystems/cargo.log", + "world_subsystems_persistence_log" = "subsystems/persistence.log", "world_subsystems_documents_log" = "subsystems/documents.log", "world_subsystems_fail2topic_log" = "subsystems/fail2topic.log", "world_subsystems_mapfinalization_log" = "subsystems/mapfinalization.log", diff --git a/code/controllers/subsystems/persistence.dm b/code/controllers/subsystems/persistence.dm new file mode 100644 index 00000000000..35c86931742 --- /dev/null +++ b/code/controllers/subsystems/persistence.dm @@ -0,0 +1,310 @@ +SUBSYSTEM_DEF(persistence) + name = "Persistence" + init_order = INIT_ORDER_PERSISTENCE + flags = SS_NO_FIRE + +/*############################################# + Internal methods +#############################################*/ + +/** + * Initialization of the persistence subsystem. Initialization includes loading all persistent data and spawning the related objects. + */ +/datum/controller/subsystem/persistence/Initialize() + . = ..() + try + if(!GLOB.config.sql_enabled) + log_subsystem_persistence("SQL configuration not enabled! Persistence subsystem requires SQL.") + return SS_INIT_SUCCESS + + GLOB.persistence_register = list() + + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence subsystem init. Failed to connect.") + return SS_INIT_FAILURE + else + // Delete all persistent objects in the database that have expired and have passed the cleanup grace period (PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS) + database_clean_entries() + + // Retrieve all persistent data that is not expired + var/list/persistent_data = database_get_active_entries() + log_subsystem_persistence("Init: Retrieved [persistent_data.len] entries for instancing this round.") + + // Instantiate all remaining entries based of their type + // Assign persistence related vars found in /obj, apply content and add to live tracking list. + for (var/data in persistent_data) + CHECK_TICK + var/typepath = text2path(data["type"]) + if (!ispath(typepath)) // Type checking + continue + var/obj/instance = new typepath() + instance.persistence_track_id = data["id"] + track_apply_content(instance, data["content"], data["x"], data["y"], data["z"]) + register_track(instance, data["author_ckey"]) + + return SS_INIT_SUCCESS + catch(var/exception/e) + log_subsystem_persistence("Panic: Exception during subsystem initialize: [e]") + return SS_INIT_FAILURE + +/** + * Shutdown of the persistence subsystem. Adds new persistent objects, removes no longer existing persistent objects and updates changed persistent objects in the database. + */ +/datum/controller/subsystem/persistence/Shutdown() + try + if(!GLOB.config.sql_enabled) + log_subsystem_persistence("SQL configuration not enabled! Panic - Cannot save current round track changes!") + return + + // Subsystem shutdown: + // Create new persistent records for objects that have been created in the round + // Update tracked objects that have an ID (already existing from previous rounds) + // Delete persistent records that no longer exist in the registry (removed during the round) + + var/created = 0 + var/updated = 0 + var/expired = 0 + + // Get already stored data before saving new tracks so we can compare what has been updated or removed during the round. + var/list/existing_data = database_get_active_entries() + + for (var/obj/track in GLOB.persistence_register) + CHECK_TICK + if (track.persistence_track_id == 0) + // Tracked object has no ID meaning it is new, create a new persistent record for it + database_add_entry(track) + created++ + + // Find tracks that have been removed during the round by trying to find the track by database ID + // If we find the track, we need to check if it requires an update instead + for (var/record in existing_data) + var/found = FALSE + for (var/obj/track in GLOB.persistence_register) + CHECK_TICK + if (record["id"] == track.persistence_track_id) + // A track with the same ID has been found in the register, it still exists, check if we need to update it instead + found = TRUE // Prevent expiration of track + var/changed = FALSE + var/turf/T = get_turf(track) + if (T && T.x != record["x"]) + changed = TRUE + else if (T && T.y != record["y"]) + changed = TRUE + else if (T && T.z != record["z"]) + changed = TRUE + else if (track_get_content(track) != record["content"]) + changed = TRUE + if (changed) + database_update_entry(track) + updated++ + break // Track found (and perhaps updated), break off loop search as it won't need to be deleted anyways + if (!found) + // No track with the same ID has been found in the register, remove it from the database (expire) + database_expire_entry(record["id"]) + expired++ + + log_subsystem_persistence("Shutdown: Tried to create [created], update [updated] and expire [expired] tracks.") + catch(var/exception/e) + log_subsystem_persistence("Panic: Exception during subsystem shutdown: [e]") + return + +/** + * Generates StatEntry. Returns information about currently tracked objects. + */ +/datum/controller/subsystem/persistence/stat_entry(msg) + msg = ("Global register tracks: [GLOB.persistence_register.len]") + return ..() + +/** + * Safely get JSON persistent content of track. + * RETURN: JSON formatted content of track or null if an exception occured. + */ +/datum/controller/subsystem/persistence/proc/track_get_content(var/obj/track) + var/result = null + try + result = json_encode(track.persistence_get_content()) + catch(var/exception/e) + log_subsystem_persistence("Track: Failed to get/encode track content: [e]") + return result + +/** + * Safely apply persistent content to track. + * PARAMS: + * track = Object to apply content to. + * json = Custom persistent content JSON to be applied. + * x,y,z = x-y-z coordinates of object, can be null. + */ +/datum/controller/subsystem/persistence/proc/track_apply_content(var/obj/track, var/json, var/x, var/y, var/z) + try + track.persistence_apply_content(json_decode(json), x, y, z) + catch(var/exception/e) + log_subsystem_persistence("Track: Failed to apply/decode track content: [e]") + +/** + * Run cleanup on the persistence entries in the database. + * Cleanup includes all entries that have expired and have passed the clean up grace period (PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS). + */ +/datum/controller/subsystem/persistence/proc/database_clean_entries() + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_clean_entries. Failed to connect.") + else + var/datum/db_query/cleanup_query = SSdbcore.NewQuery( + "DELETE FROM ss13_persistent_data WHERE DATE_ADD(expires_at, INTERVAL :grace_period_days DAY) <= NOW()", + list("grace_period_days" = PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS) + ) + + cleanup_query.SetFailCallback(CALLBACK(PROC_REF(database_clean_entries_callback_failure))) + cleanup_query.SetSuccessCallback(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(qdel))) + cleanup_query.ExecuteNoSleep(TRUE) + +/datum/controller/subsystem/persistence/proc/database_clean_entries_callback_failure(var/datum/db_query/cleanup_query) + if (cleanup_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_clean_entries. " + cleanup_query.ErrorMsg()) + qdel(cleanup_query) + +/** + * Retrieve persistent data entries that haven't expired. + * RETURN: List of JSON, with ID, author_ckey, type, content, x, y, z + */ +/datum/controller/subsystem/persistence/proc/database_get_active_entries() + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_get_active_entries. Failed to connect.") + else + var/datum/db_query/get_query = SSdbcore.NewQuery( + "SELECT id, author_ckey, type, content, x, y, z FROM ss13_persistent_data WHERE NOW() < expires_at" + ) + get_query.Execute() + + var/list/results = list() + if (get_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_get_active_entries. " + get_query.ErrorMsg()) + return + else + while (get_query.NextRow()) + CHECK_TICK + var/list/entry = list( + "id" = get_query.item[1], + "author_ckey" = get_query.item[2], + "type" = get_query.item[3], + "content" = get_query.item[4], + "x" = get_query.item[5], + "y" = get_query.item[6], + "z" = get_query.item[7] + ) + results += list(entry) + qdel(get_query) + return results + +/** + * Adds a persistent data record to the database. + */ +/datum/controller/subsystem/persistence/proc/database_add_entry(var/obj/track) + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_add_entry. Failed to connect.") + else + var/content = track_get_content(track) + if (!content) + return + + var/turf/T = get_turf(track) + if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support + return + + var/datum/db_query/insert_query = SSdbcore.NewQuery( + "INSERT INTO ss13_persistent_data (author_ckey, type, created_at, expires_at, content, x, y, z) \ + VALUES (:author_ckey, :type, NOW(), DATE_ADD(NOW(), INTERVAL :expire_in_days DAY), :content, :x, :y, :z)", + list( + "author_ckey" = track.persistence_author_ckey, + "type" = "[track.type]", + "expire_in_days" = track.persistance_expiration_time_days, + "content" = content, + "x" = T.x, + "y" = T.y, + "z" = T.z + ) + ) + insert_query.Execute() + + if (insert_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_add_entry. " + insert_query.ErrorMsg()) + qdel(insert_query) + +/** + * Updates a persistent data record in the database. + */ +/datum/controller/subsystem/persistence/proc/database_update_entry(var/obj/track) + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_update_entry. Failed to connect.") + else + var/content = track_get_content(track) + if (!content) + return + + var/turf/T = get_turf(track) + if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support + return + + var/datum/db_query/update_query = SSdbcore.NewQuery( + "UPDATE ss13_persistent_data SET author_ckey=:author_ckey, expires_at=DATE_ADD(NOW(), INTERVAL :expire_in_days DAY), content=:content, x=:x, y=:y, z=:z WHERE id = :id", + list( + "author_ckey" = track.persistence_author_ckey, + "expire_in_days" = track.persistance_expiration_time_days, + "content" = content, + "x" = T.x, + "y" = T.y, + "z" = T.z, + "id" = track.persistence_track_id + ) + ) + update_query.Execute() + + if (update_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_update_entry. " + update_query.ErrorMsg()) + qdel(update_query) + +/** + * Expire a persistent data record in the database by setting it's expiration date to now. + */ +/datum/controller/subsystem/persistence/proc/database_expire_entry(var/track_id) + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_expire_entry. Failed to connect.") + else + var/datum/db_query/expire_query = SSdbcore.NewQuery( + "UPDATE ss13_persistent_data SET expires_at=NOW() WHERE id = :id", + list("id" = track_id) + ) + expire_query.Execute() + + if (expire_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_expire_entry. " + expire_query.ErrorMsg()) + qdel(expire_query) + +/*############################################# + Public methods +#############################################*/ + +/** + * Adds the given object to the list of tracked objects. At shutdown the tracked object will be either created or updated in the database. + * The ckey is an optional argument and is used for tracking user generated content by adding an author to the persistent data. + */ +/datum/controller/subsystem/persistence/proc/register_track(var/obj/new_track, var/ckey) + if(new_track.persistence_track_active) // Prevent multiple registers per object and removes the need to check the register if it's already in there + return + + var/turf/T = get_turf(new_track) + if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support + return + + new_track.persistence_track_active = TRUE + new_track.persistence_author_ckey = ckey + GLOB.persistence_register += new_track + +/** + * Removes the given object from the list of tracked objects. At shutdown the tracked object will be remove from the database. + */ +/datum/controller/subsystem/persistence/proc/deregister_track(var/obj/old_track) + if(!old_track.persistence_track_active) // Prevent multiple deregisters per object and removes the need to check the register if it's not in there + return + + old_track.persistence_track_active = FALSE + GLOB.persistence_register -= old_track diff --git a/code/game/objects/objs.dm b/code/game/objects/objs.dm index a3e282b78db..9fbd3aa26e7 100644 --- a/code/game/objects/objs.dm +++ b/code/game/objects/objs.dm @@ -65,7 +65,23 @@ var/list/req_one_access /* END ACCESS VARS */ + /* START PERSISTENCE VARS */ + // State check if the subsystem is tracking the object, used for easy state checking without iterating the register + var/persistence_track_active = FALSE + // Tracking ID of the object used by the persistence subsystem + var/persistence_track_id = 0 + // Author ckey of the object used in persistence subsystem + // Note: Not every type can have an author, like generated dirt for example + // Additionally, the ckey is only an indicator, for example: A player could pin a paper without having written it + // This should be considered for any moderation purpose + var/persistence_author_ckey = null + // Expiration time used when saving/updating a persistent type, this can be changed depending on the use case by assigning a new value + var/persistance_expiration_time_days = PERSISTENT_DEFAULT_EXPIRATION_DAYS + /* END PERSISTENCE VARS */ + /obj/Destroy() + if(persistence_track_active) // Prevent hard deletion of references in the persistence register by removing it preemptively + SSpersistence.deregister_track(src) STOP_PROCESSING(SSprocessing, src) unbuckle() QDEL_NULL(talking_atom) @@ -324,3 +340,25 @@ /// Override this to customize the effects an activated signaler has. /obj/proc/do_signaler() return + +/*############################################# + PERSISTENT +#############################################*/ + +/** + * Called by the persistence subsystem to retrieve relevant persistent information to be stored in the database. + * Expected to be overriden by derived objects. + * RETURN: Associated list with custom information (e.g.: ["test" = "abc", "counter" = 123]) + */ +/obj/proc/persistence_get_content() + return + +/** + * Called by the persistence subsystem to apply persistent data on the created object. + * Expected to be overriden by derived objects. + * PARAMS: + * content = Associated list with custom information (e.g.: ["test" = "abc", "counter" = 123]). + * x,y,z = x-y-z coordinates of object, can be null. + */ +/obj/proc/persistence_apply_content(content, x, y, z) + return diff --git a/code/game/objects/structures/noticeboard.dm b/code/game/objects/structures/noticeboard.dm index 860a35e205a..adcc021f87f 100644 --- a/code/game/objects/structures/noticeboard.dm +++ b/code/game/objects/structures/noticeboard.dm @@ -7,8 +7,12 @@ anchored = 1 var/notices = 0 -/obj/structure/noticeboard/Initialize() +/obj/structure/noticeboard/Initialize(mapload) + if (mapload) + add_papers_from_turf() . = ..() + +/obj/structure/noticeboard/proc/add_papers_from_turf() for(var/obj/item/I in loc) if(notices > 4) break if(istype(I, /obj/item/paper)) @@ -25,6 +29,7 @@ user.drop_from_inventory(attacking_item,src) notices++ icon_state = "nboard0[notices]" //update sprite + SSpersistence.register_track(attacking_item, ckey(usr.key)) // Add paper to persistent tracker to_chat(user, SPAN_NOTICE("You pin the paper to the noticeboard.")) else to_chat(user, SPAN_NOTICE("You reach to pin your paper to the board but hesitate. You are certain your paper will not be seen among the many others already attached.")) @@ -58,6 +63,7 @@ add_fingerprint(usr) notices-- icon_state = "nboard0[notices]" + SSpersistence.deregister_track(P) // Remove paper from persistent tracker if(href_list["write"]) if((usr.stat || usr.restrained())) //For when a player is handcuffed while they have the notice window open return diff --git a/code/modules/paperwork/paper.dm b/code/modules/paperwork/paper.dm index 962377a74a0..4af1e13fe35 100644 --- a/code/modules/paperwork/paper.dm +++ b/code/modules/paperwork/paper.dm @@ -791,6 +791,25 @@ . = ..() scan_target = WEAKREF(set_scan_target) +/*############################################# + PERSISTENT +#############################################*/ + +/obj/item/paper/persistence_get_content() + var/list/content = list() + content["title"] = name + content["text"] = info + return content + +/obj/item/paper/persistence_apply_content(content, x, y, z) + set_content(content["title"], content["text"]) + src.x = x + src.y = y + src.z = z + for(var/obj/object in loc) // Pin to noticeboard + if(istype(object, /obj/structure/noticeboard)) + var/obj/structure/noticeboard/notice_board = object + notice_board.add_papers_from_turf() /*############################################# diff --git a/config/example/logging.json b/config/example/logging.json index cbbd4bc4961..afa00af4801 100644 --- a/config/example/logging.json +++ b/config/example/logging.json @@ -33,6 +33,7 @@ "log_subsystems_ghostroles":1, "log_subsystems_law":1, "log_subsystems_cargo":1, + "log_subsystems_persistence":1, "log_subsystems_discord":0, "log_subsystems_documents":1, "log_subsystems_fail2topic":1, diff --git a/config/example/logging_files.json b/config/example/logging_files.json index b1e11087a22..49410734f0c 100644 --- a/config/example/logging_files.json +++ b/config/example/logging_files.json @@ -33,6 +33,7 @@ "world_subsystems_ghostroles_log":"subsystems/ghostroles.log", "world_subsystems_law_log":"subsystems/law.log", "world_subsystems_cargo_log":"subsystems/cargo.log", + "world_subsystems_persistence_log":"subsystems/persistence.log", "world_subsystems_discord_log":"subsystems/discord.log", "world_subsystems_documents_log":"subsystems/documents.log", "world_subsystems_fail2topic_log":"subsystems/fail2topic.log", diff --git a/html/changelogs/fabiank3-persistent-data-framework.yml b/html/changelogs/fabiank3-persistent-data-framework.yml new file mode 100644 index 00000000000..b76e4653ced --- /dev/null +++ b/html/changelogs/fabiank3-persistent-data-framework.yml @@ -0,0 +1,8 @@ +author: FabianK3 + +delete-after: True + +changes: + - rscadd: "Added subsystem for persistent data between rounds. The framework includes the subsystem responsible for managing persistent data and provides functionality for objects to be added to said system. See PR and GitHub Wiki documentation for more technical details." + - config: "Added new log type for the persistence subsystem." + - experiment: "Added papers and noticeboards to the persistence subsystem. Papers and their contents (title and text) will now be persistent across rounds when they get pinned to noticeboards. They will loose persistence when they either get unpinned during a round or after no changes within 30 days. Unpinning and re-pinning will keep them persistent but not update the 30 days expiration date; Unpinning, editing and re-pinning them will reset the 30 days expiration date."