/** * This tool is for simplifying writing and reading persistent user data. * All it requires is a setup of database + json pointer to data and it will * take care of data caching and database connection. * Copyright 2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * * Acedia is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License, or * (at your option) any later version. * * Acedia is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ class PersistentDataManager extends AcediaObject; /** * # `PersistentDataManager` * * This tool is for simplifying writing and reading persistent user data. * All it requires is a setup of database + json pointer to data and it will * take care of data caching and database connection. * * ## Usage * * Create an instance and use `Setup()` to connect to the database with * persistent data. You can use `Setup()` again on the same object to setup * a different database as a source. All data will be automatically reloaded. * After that you can use `GetPersistentData()`/`SetPersistentData()` to * read/write persistent data for the particular user. * Since loading data from the database takes time, you don't have an * immediate access to it. * But you can use `_.users.OnPersistentDataAvailable()` signal to track * whenever new user data from database becomes available. However, you can * start writing persistent data (and reading what you've wrote) at any time it * - these changes will be reapplied whenever data is actually loaded from * database. * * ## Implementation * * Implementation consists of simply creating `DBConnection` for every user * and storing them in the `HashTable` that maps user IDs into those * `DBConnection`s. * We also maintain a reverse map to figure out what `DBConnection` belongs * to what user when connection signals an update. We borrow the signal that * `UsersAPI` provides to inform everyone interested about which users * have updated. */ var private bool initialized; var private Database database; var private JSONPointer rootPointer; var private HashTable userToConnection, connectionToUser; var private PersistentDataManager_OnPersistentDataReady_Signal onPersistentDataReadySignal; protected function Constructor() { _.players.OnNewPlayer(self).connect = ConnectPersistentData; onPersistentDataReadySignal = _.users._getOnReadySignal(); } protected function Finalizer() { Reset(); _.players.OnNewPlayer(self).Disconnect(); } private final function Reset() { _.memory.Free(database); _.memory.Free(rootPointer); _.memory.Free(userToConnection); _.memory.Free(connectionToUser); _.memory.Free(onPersistentDataReadySignal); database = none; rootPointer = none; userToConnection = none; connectionToUser = none; onPersistentDataReadySignal = none; initialized = false; } /** * Sets up database and location inside it as a source of users' persistent * data. * * Must be successfully called at least once for the caller * `PersistentDataManager` to be usable. * * @param db Database inside which persistent data is stored. * @param location Location inside specified database to the root of * persistent data. * @return `true` if setup was successful (requires both arguments to be not * `none`) and `false` otherwise. */ public final function bool Setup(Database db, JSONPointer location) { if (db == none) return false; if (location == none) return false; Reset(); database = db; database.NewRef(); rootPointer = location.Copy(); userToConnection = _.collections.EmptyHashTable(); connectionToUser = _.collections.EmptyHashTable(); // Using `userToConnection` as an empty hash table, not related to its // actual meaning database.IncrementData(location, userToConnection); initialized = true; return true; } /** * Reads specified named persistent data for the specified group. * * @param id ID of the user to read persistent data from. * @param groupName Group to which this persistent data belongs to. * Groups are used as namespaces to avoid duplicate persistent variables * between mods. If your mod needs several subgroups, its recommended to * use the same prefix for them, e.g. "MyAwesomeMod.economy" and * "MyAwesomeMod.enemies". * @param dataName Name of persistent data variable to read inside * `groupName` persistent data group. Not `none` value must be provided. * @param data Data to set as persistent value. Must be * JSON-compatible. If `none` is passed, returns the all data for * the given group. * @return Data read from the persistent variable. `none` in case of any kind * of failure. */ public final function AcediaObject GetPersistentData( UserID id, BaseText groupName, optional BaseText dataName) { local AcediaObject result; local Text textID; local JSONPointer location; local DBConnection relevantConnection; if (!initialized) return none; if (id == none) return none; if (groupName == none) return none; textID = id.GetUniqueID(); relevantConnection = DBConnection(userToConnection.GetItem(textID)); textID.FreeSelf(); if (relevantConnection != none) { location = _.json.Pointer(); location.Push(groupName); if (dataName != none) { location.Push(dataName); } result = relevantConnection.ReadDataByJSON(location); relevantConnection.FreeSelf(); location.FreeSelf(); } return result; } /** * Writes specified named persistent data for the specified group. * * @param id ID of the user to change persistent data of. * @param groupName Group to which this persistent data belongs to. * Groups are used as namespaces to avoid duplicate persistent variables * between mods. If your mod needs several subgroups, its recommended to * use the same prefix for them, e.g. "MyAwesomeMod.economy" and * "MyAwesomeMod.enemies". * @param dataName Name of persistent data variable to change inside * `groupName` persistent data group. * @param data Data to set as persistent value. Must be * JSON-compatible. * @return `true` if change succeeded in local cached version of database with * persistent values and `false` otherwise. Such local changes can * potentially be not applied to the actual database. But successful local * changes should persist for the game session. */ public final function bool WritePersistentData( UserID id, BaseText groupName, BaseText dataName, AcediaObject data) { local bool result; local Text textID; local JSONPointer location; local DBConnection relevantConnection; local HashTable emptyObject; if (!initialized) return false; if (id == none) return false; if (groupName == none) return false; if (dataName == none) return false; textID = id.GetUniqueID(); relevantConnection = DBConnection(userToConnection.GetItem(textID)); textID.FreeSelf(); if (relevantConnection != none) { emptyObject = _.collections.EmptyHashTable(); location = _.json.Pointer(); location.Push(groupName); relevantConnection.IncrementDataByJSON(location, emptyObject); location.Push(dataName); result = relevantConnection.WriteDataByJSON(location, data); relevantConnection.FreeSelf(); location.FreeSelf(); emptyObject.FreeSelf(); } return result; } /** * Increments specified named persistent data for the specified group. * * @param id ID of the user to change persistent data of. * @param groupName Group to which this persistent data belongs to. * Groups are used as namespaces to avoid duplicate persistent variables * between mods. If your mod needs several subgroups, its recommended to * use the same prefix for them, e.g. "MyAwesomeMod.economy" and * "MyAwesomeMod.enemies". * @param dataName Name of persistent data variable to change inside * `groupName` persistent data group. * @param data Data by which to increment existing persistent value. * Must be JSON-compatible. * @return `true` if change succeeded in local cached version of database with * persistent values and `false` otherwise. Such local changes can * potentially be not applied to the actual database. But successful local * changes should persist for the game session. */ public final function bool IncrementPersistentData( UserID id, BaseText groupName, BaseText dataName, AcediaObject data) { local bool result; local Text textID; local JSONPointer location; local DBConnection relevantConnection; if (!initialized) return false; if (id == none) return false; if (groupName == none) return false; if (dataName == none) return false; textID = id.GetUniqueID(); relevantConnection = DBConnection(userToConnection.GetItem(textID)); textID.FreeSelf(); if (relevantConnection != none) { location = _.json.Pointer(); location.Push(groupName).Push(dataName); result = relevantConnection.IncrementDataByJSON(location, data); relevantConnection.FreeSelf(); location.FreeSelf(); } return result; } /** * Removes specified named persistent data for the specified group. * * @param id ID of the user to remove persistent data of. * @param groupName Group to which this persistent data belongs to. * Groups are used as namespaces to avoid duplicate persistent variables * between mods. If your mod needs several subgroups, its recommended to * use the same prefix for them, e.g. "MyAwesomeMod.economy" and * "MyAwesomeMod.enemies". * @param dataName Name of persistent data variable to remove inside * `groupName` persistent data group. * @return `true` if removal succeeded in local cached version of database with * persistent values and `false` otherwise. Such local changes can * potentially be not applied to the actual database. But successful local * changes should persist for the game session. */ public final function bool RemovePersistentData( UserID id, BaseText groupName, BaseText dataName) { local bool result; local Text textID; local JSONPointer location; local DBConnection relevantConnection; if (!initialized) return false; if (id == none) return false; if (groupName == none) return false; if (dataName == none) return false; textID = id.GetUniqueID(); relevantConnection = DBConnection(userToConnection.GetItem(textID)); textID.FreeSelf(); if (relevantConnection != none) { location = _.json.Pointer(); location.Push(groupName).Push(dataName); result = relevantConnection.RemoveDataByJSON(location); relevantConnection.FreeSelf(); location.FreeSelf(); } return result; } /** * Connects and starts synchronizing persistent data for the given player. * * @param player Player to synchronize persistent data for. */ public final function ConnectPersistentData(EPlayer player) { local UserID playerID; if (initialized && player != none) { playerID = player.GetUserID(); ConnectPersistentDataByID(playerID); _.memory.Free(playerID); } } /** * Connects and starts synchronizing persistent data for the player given by * their ID. * * @param id User ID for which to synchronize persistent data from * the database. */ public final function ConnectPersistentDataByID(UserID id) { local Text textID; local DBConnection newConnection; if (!initialized) return; if (id == none) return; textID = id.GetUniqueID(); if (userToConnection.HasKey(textID)) { _.memory.Free(textID); return; } rootPointer.Push(textID); newConnection = DBConnection(_.memory.Allocate(class'DBConnection')); newConnection.Initialize(database, rootPointer); _.memory.Free(rootPointer.Pop()); newConnection.Connect(); userToConnection.SetItem(textID, newConnection); connectionToUser.SetItem(newConnection, textID); newConnection.OnStateChanged(self).connect = UserUpdated; textID.FreeSelf(); newConnection.FreeSelf(); } private final function UserUpdated( DBConnection instance, DBConnection.DBConnectionState oldState, DBConnection.DBConnectionState newState) { local UserID id; if (!initialized) return; if (newState == DBCS_Connecting) return; if (onPersistentDataReadySignal == none) return; if (!onPersistentDataReadySignal.IsAllocated()) return; id = UserID(connectionToUser.GetItem(instance)); if (id != none) { onPersistentDataReadySignal.Emit(id, newState == DBCS_Connected); id.FreeSelf(); } } /** * Attempts to start persistent data synchronization for all players currently * on the server. */ public final function LoadCurrentPlayers() { local int i; local array currentPlayers; if (initialized) { currentPlayers = _.players.GetAll(); for (i = 0; i < currentPlayers.length; i += 1) { ConnectPersistentData(currentPlayers[i]); } _.memory.FreeMany(currentPlayers); } } defaultproperties { }